Parmelia Docs

Parmelia Payments API

Accept stablecoin payments (USDC on Arbitrum) with a Stripe-style flow: create a Payment Intent, share a checkout link or QR, and receive a signed webhook when it’s paid. No blockchain knowledge required from your customers.

This reference documents what is live today. Items marked (roadmap) are designed but not yet enabled.


Authentication

Authenticate every /v1 request with a secret API key in the Authorization header:

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

A missing or invalid key returns 401:

{ "error": "Invalid or revoked API key." }

Quickstart

# 1. Create a payment intent for 25 USDC, tagged with your order id.
curl -X POST https://server.parmelia.workers.dev/v1/payment_intents \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{"amount":"25.00","currency":"USDC","metadata":{"order_id":"A-1042"}}'
{
  "id": "pi_3b1c…",
  "object": "payment_intent",
  "status": "awaiting_payment",
  "amount": "25.00",
  "currency": "USDC",
  "reference": null,
  "metadata": { "order_id": "A-1042" },
  "checkout_url": "https://app.parmelia.me/pay?id=…",
  "tx_hash": null,
  "mode": "test",
  "expires_at": "2026-06-16T15:00:00.000Z",
  "created_at": "2026-06-16T14:00:00.000Z"
}
  1. Show the checkout_url to your customer (render it as a QR, or link to it).
  2. When the customer pays, Parmelia sends a signed payment.paid webhook to your registered endpoint, with your metadata echoed back. Deliver the product.

Payment flows

A payment intent can be paid two ways. You create the intent the same way for both; the difference is who pays and how.

Flow A — pay with a Parmelia account (default)

The customer opens checkout_url in Parmelia and pays with their passkey. Gas is sponsored; nothing is needed from you beyond the checkout_url. Best when your customers are (or will become) Parmelia users.

Flow B — pay from any external wallet (on-chain)

Lets anyone pay from any wallet (MetaMask, an exchange withdrawal, another dapp) without a Parmelia account, via the on-chain PaymentRouter. You request a signed authorization and the payer’s wallet calls the router. Funds go directly to the merchant; Parmelia never custodies them. See On-chain authorization.


Payment Intents

The payment intent object

FieldTypeDescription
idstringUnique id, prefixed pi_.
objectstringAlways "payment_intent".
statusstringSee statuses.
amountstringDecimal amount, e.g. "25.00".
currencystringAsset symbol, e.g. "USDC".
referencestring | nullYour free-text note (≤200 chars).
metadataobjectYour opaque key/values, echoed in webhooks.
checkout_urlstringHosted checkout link (Flow A).
tx_hashstring | nullOn-chain tx hash once paid.
modestringtest or live.
expires_atstringISO 8601 UTC.
created_atstringISO 8601 UTC.

Statuses

StatusMeaning
awaiting_paymentCreated, waiting for the customer to pay.
paidPayment confirmed; funds are in the merchant’s account. Fires payment.paid.
canceledCanceled by the merchant before payment.
expiredPast expires_at without payment. (Auto-expiry is roadmap; treat expires_at as advisory and cancel intents you no longer want.)

Create a payment intent

POST /v1/payment_intents

Body fieldRequiredDescription
amountyesDecimal string, e.g. "25.00". Must be > 0.
currencynoAsset symbol; defaults to USDC. Must be supported on the active chain.
metadatanoObject of your own keys (order_id, invoice_id, …). Max 8 KB.
referencenoShort note (≤200 chars).
expires_innoSeconds until expiry, 60–86400. Default 3600.

Headers:

Returns 201 with the payment intent (or 200 if an idempotent replay matched).

curl -X POST https://server.parmelia.workers.dev/v1/payment_intents \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Idempotency-Key: order-A-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "25.00",
    "currency": "USDC",
    "reference": "Order A-1042",
    "metadata": { "order_id": "A-1042", "customer_id": "u_88" },
    "expires_in": 1800
  }'

Retrieve a payment intent

GET /v1/payment_intents/{id} → the payment intent object (or 404).

List payment intents

GET /v1/payment_intents{ "object": "list", "data": [ … ] } (most recent first).

Cancel a payment intent

POST /v1/payment_intents/{id}/cancel → the updated intent. Only valid while awaiting_payment; otherwise returns 409 INTENT_NOT_PAYABLE.

Get an on-chain authorization (Flow B)

GET /v1/payment_intents/{id}/onchain

Returns a Parmelia-signed authorization the payer’s wallet uses to call the PaymentRouter. The signature binds the chain, router, invoice, token, amount, merchant and fee — the payer cannot change any of them.

{
  "router": "0x…",
  "chainId": 421614,
  "invoiceId": "0x…",
  "token": "0x…",
  "amount": "25000000",
  "merchant": "0x…",
  "feeBps": 0,
  "deadline": 1750000000,
  "signature": "0x…",
  "call": {
    "function": "payInvoice(bytes32,address,uint256,address,uint256,uint256,bytes,bytes)",
    "args": "invoiceId, token, amount, merchant, feeBps, deadline, signature, metadata(0x)"
  }
}

The payer’s wallet then sends two transactions on the active Arbitrum chain:

// 1) allow the router to pull the tokens
USDC.approve(router, amount)

// 2) pay — funds go straight to `merchant`, fee (if any) to the treasury
PaymentRouter.payInvoice(invoiceId, token, amount, merchant, feeBps, deadline, signature, "0x")

When the router’s InvoicePaid event is observed, the intent flips to paid and the payment.paid webhook fires. Notes:

Simulate a payment (test mode)

POST /v1/payment_intents/{id}/simulate_payment

Test keys only. Marks the intent paid without any on-chain payment and fires the payment.paid webhook — so you can build and verify your webhook handler in minutes without acquiring testnet funds. Returns 400 SANDBOX_ONLY with a live key, 409 INTENT_NOT_PAYABLE if not awaiting payment.

curl -X POST https://server.parmelia.workers.dev/v1/payment_intents/pi_3b1c…/simulate_payment \
  -H "Authorization: Bearer sk_test_xxx"

Events

Every state change is recorded as an immutable event (the source of webhooks).

The event object

{
  "id": "evt_…",
  "merchantId": "mer_…",
  "type": "payment.paid",
  "objectId": "pi_3b1c…",
  "payload": { "...": "the payment intent object" },
  "mode": "test",
  "createdAt": "2026-06-16T14:05:00.000Z"
}

Event types

TypeFires when
payment.createdA payment intent is created.
payment.paidA payment intent is confirmed paid (Flow A, Flow B, or sandbox).

(Roadmap: payment.expired, payment.failed, payment.refunded.)


Webhooks

Register your endpoint URL in the dashboard; you receive a signing secret (whsec_…) once. Parmelia POSTs each event to your URL.

Request

POST <your endpoint>
Content-Type: application/json
Parmelia-Signature: <hmac-sha256 hex>
Parmelia-Timestamp: <unix seconds>
Parmelia-Event-Id: evt_…

Body:

{
  "id": "evt_…",
  "type": "payment.paid",
  "created": "2026-06-16T14:05:00.000Z",
  "data": {
    "id": "pi_3b1c…",
    "object": "payment_intent",
    "status": "paid",
    "amount": "25.00",
    "currency": "USDC",
    "reference": "Order A-1042",
    "metadata": { "order_id": "A-1042" },
    "tx_hash": "0x…",
    "mode": "test"
  }
}

Verifying the signature

Compute HMAC-SHA256(secret, "<timestamp>.<raw body>") and compare, in constant time, to the Parmelia-Signature header. Reject stale timestamps to prevent replay.

import crypto from "node:crypto";

export function verifyParmeliaWebhook(rawBody, headers, secret) {
  const ts = headers["parmelia-timestamp"];
  const signature = headers["parmelia-signature"];
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  const ok = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) throw new Error("Invalid signature");

  // Reject events older than 5 minutes (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("Stale webhook");

  return JSON.parse(rawBody);
}

Verify against the raw request body, byte-for-byte. Re-serializing the JSON will change the bytes and break the signature.

Idempotency & retries


Metadata

metadata is an opaque object you control (e.g. order_id, invoice_id, product_id, customer_id). It is stored on the intent and returned verbatim in the object and in every webhook, so you can reconcile the payment in your own system. Max ~8 KB.

Idempotency

Send Idempotency-Key on POST /v1/payment_intents. The same key always returns the first intent created with it, so safe retries never double-charge.

Test mode / sandbox


Errors

Errors use standard HTTP status codes and a JSON body:

{
  "error": "Human-readable message.",
  "error_code": "STABLE_CODE",
  "requestId": "…"
}
HTTPerror_codeMeaning
400INVALID_AMOUNTamount missing or ≤ 0.
400UNSUPPORTED_CURRENCYcurrency not supported on the active chain.
400METADATA_TOO_LARGEmetadata exceeds 8 KB.
400INVALID_EXPIRYexpires_in out of the 60–86400 range.
400NO_WALLETThe merchant has no receiving wallet yet.
400ROUTER_DISABLEDOn-chain pay unavailable on the active chain.
400SANDBOX_ONLYsimulate_payment called with a live key.
401(none)Missing, invalid, or revoked API key.
404INTENT_NOT_FOUNDNo such payment intent for this account.
404EVENT_NOT_FOUNDNo such event for this account.
409INTENT_NOT_PAYABLEIntent is not in a state that allows the action (e.g. cancel/pay an already-paid intent).
500SERVER_ERRORUnexpected error on our side. Retry; if it persists, contact support with requestId.

Reference: on-chain (Flow B)

ItemValue
Chain (test)Arbitrum Sepolia (421614)
Chain (live)Arbitrum One (42161)
PaymentRouterreturned by GET /v1/payment_intents/{id}/onchain (router)
payInvoicepayInvoice(bytes32 invoiceId, address token, uint256 amount, address merchant, uint256 feeBps, uint256 deadline, bytes signature, bytes metadata)
InvoicePaidevent InvoicePaid(bytes32 indexed invoiceId, address indexed payer, address indexed merchant, address token, uint256 amount, uint256 fee, bytes metadata)