Help
14. Developers
On this page
This section is for merchants who want to ask PortaWallet users for a mandate and get paid.
Status: USDC is the only token accepted for mandates. The SDK hasn't been published to a package registry yet. Ask Porta for access, a merchant key, and the list of chain IDs enabled for your account.
How it works
- Your backend creates a mandate request with your merchant key.
- You show the user the request link or QR code, plus the request ID:
https://t.me/PortaWallet_bot?startapp=mr_<requestId>. - The user reviews it and approves it in PortaWallet. The approval is signed on-chain by the user's own wallet.
- You learn about the approval from a webhook and/or by polling the status. The API is the source of truth. Use webhooks as a hint and confirm with
GETstatus before you unlock anything.
What you get from Porta:
- a merchant key
- your webhook URL registered (HTTPS, port 443, public DNS name)
- a webhook secret (≥ 32 characters)
Authentication
Send your key server-side in the header X-Porta-Merchant-Key (32–256 characters).
- Never put it in a browser, an app or logs.
- Porta stores only a SHA-256 hash of your key.
- A missing or wrong key returns
401 {"error":"unauthorized"}.
Base URL: https://api.porta.finance/api. All paths below are relative to it.
Create a mandate request
POST /v1/mandates/request · Content-Type: application/json · body ≤ 4096 bytes
| Field | Type | Rules |
|---|---|---|
payee | string | 0x address that receives payments. It can't be the token address or a blocked address. |
cap | string | USDC per month. Must be > 0 and ≤ 10000, with at most 6 decimals. |
months | integer | 1–36 |
token | string | The USDC contract address on chainId. Other tokens return bad_token. |
chainId | integer | The chain ID of the network (see Networks) |
metered | boolean, optional | true creates a usage-billed grant (see Metered billing) |
- Unknown fields are rejected.
- Anything that looks like key material is refused. Never send private keys or seeds.
201 response
{ "requestId": "92e0cf10a35d49dba000319c835570aa", "deepLink": "porta://mandate/request?payee=…&token=…&amount=…&months=…&chainId=<chainId>&rid=…", "payee": "0x…", "cap": "1", "months": 1 }- The request ID is 32 hex characters.
- A request expires 24 hours after it's created if nobody approves it.
Errors ({"error":"<code>"}):
| Status | Code |
|---|---|
| 400 | not_object, unknown_field, key_material, bad_metered, missing_payee, bad_payee, payee_blocked, payee_is_token, missing_chain, bad_chain, missing_token, bad_token, missing_cap, bad_cap, missing_months, bad_months, metering_unavailable, payee_is_session_signer |
| 401 | unauthorized |
| 413 | payload_too_large |
| 415 | unsupported_media_type |
| 503 | not_configured |
| 500 | internal |
Request status
GET /v1/mandates/request/:requestId returns { requestId, status }.
statusis one ofpending,approved,revokedorexpired.- Once approved, the response also includes
mandateId,txHash,grantor,pausedandkind. - An unknown request, or one that belongs to another merchant, returns
404 not_found.
Live on-chain status
GET /v1/mandates/request/:requestId/onchain works for approved requests only.
Response fields:
status: one ofactive,paused,depleted,expiredorrevokedcapUnits,spentUnitsandremainingUnits: base units (USDC has 6 decimals)chainIdvalidUntil
Errors:
409 not_approved502 chain_unavailable503 rpc_not_configured
Cancel a pending request
POST /v1/mandates/request/:requestId/revoke returns 200 {"requestId","status":"revoked"}.
- If the user has already approved, you get
409 approved_onchain_revoke_by_wallet_owner. Only the wallet owner can revoke an approved mandate, on-chain.
Public request terms
GET /v1/mandates/request/:requestId/terms needs no authentication. It returns only the non-secret terms of a pending request. This is what PortaWallet uses to show the review screen.
- Rate limit: 60 requests per minute per IP. Over the limit you get
429 rate_limited.
Metered billing: report usage
For requests created with "metered": true, Porta's settlement address signs the collections, and you report usage to Porta. Porta settles on-chain to your payee once the threshold is reached, and otherwise 24 hours after the last charge.
POST /v1/usage (merchant key)
| Field | Rules |
|---|---|
mandateId | bytes32 hex |
amount | Token base units, as a string matching ^[1-9]\d{0,40}$ |
idempotencyKey | 8–128 characters from [A-Za-z0-9._:-] |
chainId | Optional. Needed only if the server answers ambiguous_mandate. |
Responses:
201 { accepted, accrued, state, sessionExpiresAt?, renewSoon? }200for a duplicate request with the same idempotency key402 limit_exceeded { accrued, limit, cap }409 mandate_not_active(for examplesession_expired),mandate_suspended,idempotency_key_reused,metering_disabled,contentionorambiguous_mandate(withchainIds)404 mandate_not_found400 bad_*503 chain_unavailableornot_configured
GET /v1/metering/config?chainId=<chainId> returns the public metering settings for the chain: version, sessionSigner, decimals, thresholdBase, creditCapBase, settleIntervalSeconds and more. If metering isn't available, you get 404 metering_unavailable.
SDK (TypeScript)
The SDK is a thin client for the API.
- It never holds or accepts private keys. Passing anything that looks like key material throws
key_material_refused. - It only talks to
https://base URLs.
import { createPortaMandates } from "src/sdk/portaMandates"; // package not yet published
const porta = createPortaMandates({
baseUrl: "https://api.porta.finance/api",
chainId: CHAIN_ID, // a supported network's chain ID
getAuthHeaders: () => ({ "X-Porta-Merchant-Key": process.env.PORTA_MERCHANT_KEY! }),
});
// Set the terms on your server. Never take payee or cap from the browser.
const r = await porta.createRequest({ payee, cap: "1", months: 1, token: USDC_ADDRESS, chainId: CHAIN_ID });
r.requestId; // show it to the user
r.telegramLink; // https://t.me/PortaWallet_bot?startapp=mr_<rid> — QR code / button
r.deepLink; // porta://mandate/request?...
await porta.getStatus(r.requestId); // pending | approved | revoked | expired
await porta.getOnchainStatus(r.requestId); // active | paused | depleted | expired | revoked (approved only)
await porta.waitApproved(r.requestId, { timeoutMs: 600_000 }); // approved | revoked | expired | timeoutOther methods:
createMandate({ …, onLink, wait })creates a request and, if you ask it to, waits for approval.listDeliveries({ limit, requestId })andredeliver(eventId)manage webhook deliveries.reportUsage({ mandateId, amount, idempotencyKey, chainId? })reports metered usage.
waitApproved polling: every 3 s by default (clamped to 1–30 s), with a 10-minute default timeout.
Errors: all errors throw PortaMandatesError with a .code, such as rejected, http_error (with .status), network, response_mismatch, bad_response, on_link_failed, unsupported_chain, key_material_refused, or one of the usage codes (limit_exceeded, mandate_not_active, …).
Request link tip: some Telegram clients don't pass startapp to the Mini App. Always display the raw request ID as well. The user can paste it under Mandates → ⋯ → Paste request link or ID.
Webhooks
Porta sends a POST with a JSON body to your registered HTTPS URL.
Event types:
| Event | When |
|---|---|
mandate.approved | The user's approval is confirmed on-chain. Fields: id, type, chainId, requestId, mandateId, txHash, grantor?, createdAt. |
mandate.revoked | The request or mandate was revoked. source is merchant or chain. |
payment.succeeded | A pull settled. Includes token, amountUnits, remainingUnits, txHash and blockNumber. |
payment.failed | A pull failed. Includes userOpHash and reason. |
mandate.approaching_limit | 20% or less of the cap is left, or the cap is used up. |
metering.threshold_reached | Metered usage reached the settlement threshold or is near the cap. |
settlement.succeeded, settlement.failed | Metered settlement result |
session.expiring | A metered session is about to expire |
Verify the signature
Headers:
Porta-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>Porta-Webhook-Id: evt_<32 hex>. This matches the body'sid.
Verify against the raw body, and reject anything more than 300 seconds old.
const crypto = require("crypto");
app.post("/porta/webhook", express.raw({ type: "application/json" }), (req, res) => {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get("Porta-Signature") || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return res.sendStatus(400);
const mac = crypto.createHmac("sha256", process.env.PORTA_WEBHOOK_SECRET)
.update(m[1] + ".").update(req.body).digest();
const got = Buffer.from(m[2], "hex");
if (got.length !== mac.length || !crypto.timingSafeEqual(got, mac)) return res.sendStatus(400);
const evt = JSON.parse(req.body.toString("utf8"));
if (evt.id !== req.get("Porta-Webhook-Id")) return res.sendStatus(400);
// skip if evt.id already processed; then re-check state with GET /v1/mandates/request/:id
res.sendStatus(204);
});With the SDK: call verifyPortaWebhook({ secret, signature, webhookId, body }). It returns { ok, event } or { ok: false, reason }, where reason is bad_secret, bad_header, stale, bad_signature, bad_body or no_webcrypto. The current SDK version accepts only mandate.approved events. Use the plain verification above for the other event types. createWebhookDeduper helps you skip repeated event IDs: it keeps them for 7 days, and while one is still being processed you should answer 409.
Delivery semantics
- Don't rely on webhooks alone. The API is the source of truth, so always reconcile with
GET /v1/mandates/request/:id. - What counts as delivered: any
2xxresponse. Porta waits up to 5 s and doesn't follow redirects. - Retries: failed deliveries are retried at about +1 min, +5 min, +30 min, +2 h and +12 h. After 6 attempts the delivery is marked dead, and no further automatic delivery happens. Because of this, you may get the same event more than once. Deduplicate by
id. - Replays:
GET /v1/mandates/webhooks/deliveries?limit=1–200 (default 50)&requestId=&chainId=lists deliveries.POST /v1/mandates/webhooks/deliveries/:id/redeliverreturns202. Possible errors:404,409 in_flight|cancelled,429 cooldown|rate_limited(60 s cooldown per event, 30 per minute per merchant).