Help

14. Developers

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

  1. Your backend creates a mandate request with your merchant key.
  2. You show the user the request link or QR code, plus the request ID: https://t.me/PortaWallet_bot?startapp=mr_<requestId>.
  3. The user reviews it and approves it in PortaWallet. The approval is signed on-chain by the user's own wallet.
  4. 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 GET status 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

FieldTypeRules
payeestring0x address that receives payments. It can't be the token address or a blocked address.
capstringUSDC per month. Must be > 0 and ≤ 10000, with at most 6 decimals.
monthsinteger1–36
tokenstringThe USDC contract address on chainId. Other tokens return bad_token.
chainIdintegerThe chain ID of the network (see Networks)
meteredboolean, optionaltrue 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

json
{ "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>"}):

StatusCode
400not_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
401unauthorized
413payload_too_large
415unsupported_media_type
503not_configured
500internal

Request status

GET /v1/mandates/request/:requestId returns { requestId, status }.

  • status is one of pending, approved, revoked or expired.
  • Once approved, the response also includes mandateId, txHash, grantor, paused and kind.
  • 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 of active, paused, depleted, expired or revoked
  • capUnits, spentUnits and remainingUnits: base units (USDC has 6 decimals)
  • chainId
  • validUntil

Errors:

  • 409 not_approved
  • 502 chain_unavailable
  • 503 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)

FieldRules
mandateIdbytes32 hex
amountToken base units, as a string matching ^[1-9]\d{0,40}$
idempotencyKey8–128 characters from [A-Za-z0-9._:-]
chainIdOptional. Needed only if the server answers ambiguous_mandate.

Responses:

  • 201 { accepted, accrued, state, sessionExpiresAt?, renewSoon? }
  • 200 for a duplicate request with the same idempotency key
  • 402 limit_exceeded { accrued, limit, cap }
  • 409 mandate_not_active (for example session_expired), mandate_suspended, idempotency_key_reused, metering_disabled, contention or ambiguous_mandate (with chainIds)
  • 404 mandate_not_found
  • 400 bad_*
  • 503 chain_unavailable or not_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.
ts
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 | timeout

Other methods:

  • createMandate({ …, onLink, wait }) creates a request and, if you ask it to, waits for approval.
  • listDeliveries({ limit, requestId }) and redeliver(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:

EventWhen
mandate.approvedThe user's approval is confirmed on-chain. Fields: id, type, chainId, requestId, mandateId, txHash, grantor?, createdAt.
mandate.revokedThe request or mandate was revoked. source is merchant or chain.
payment.succeededA pull settled. Includes token, amountUnits, remainingUnits, txHash and blockNumber.
payment.failedA pull failed. Includes userOpHash and reason.
mandate.approaching_limit20% or less of the cap is left, or the cap is used up.
metering.threshold_reachedMetered usage reached the settlement threshold or is near the cap.
settlement.succeeded, settlement.failedMetered settlement result
session.expiringA 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's id.

Verify against the raw body, and reject anything more than 300 seconds old.

js
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 2xx response. 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/redeliver returns 202. Possible errors: 404, 409 in_flight|cancelled, 429 cooldown|rate_limited (60 s cooldown per event, 30 per minute per merchant).

All help topicsBack to Porta