PAYMENT PROCESSOR API

Accept your first payment

Create an API key, present a server-generated payment QR, and receive signed payment updates.

Step 1

Create an API key

Every API request is authenticated with a bearer key. If this is your first time here, open the at GET /dashboard and submit the signup form: it calls POST /v1/accounts, saves the returned payment_processor_sk_… key in sessionStorage, signs you in, and reveals the secret once.

There is no username/password login. Returning integrators sign in by pasting an existing API key into the dashboard’s session form.

Point your shell at the API

Terminal
export PAYMENT_PROCESSOR_API_URL="<current docs origin>"
export PAYMENT_PROCESSOR_API_KEY="payment_processor_sk_..."

This page filled PAYMENT_PROCESSOR_API_URL with its own origin, so copied commands run against the host serving these docs. Replace payment_processor_sk_... with the key from your dashboard — this page never asks for, or stores, your secrets.

Create another key over the API

Once you hold an active key, mint additional keys with POST /v1/api_keys — for example one key per environment. It requires an existing active key, so the dashboard remains the recommended path for your first key.

Request — POST /v1/api_keys
curl -X POST "$PAYMENT_PROCESSOR_API_URL/v1/api_keys" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label":"Production"}'
Response — 201 Created
{
  "id": "key_01K0PAYMENTPROCESSOR0000000000000000",
  "object": "api_key",
  "label": "Production",
  "last4": "X7q9",
  "created_at": "2026-07-20T12:00:00.000Z",
  "revoked_at": null,
  "secret": "payment_processor_sk_..."
}

secret is returned once, in this response only. Store it now — every later read of this key exposes only last4.

Step 2

Register a webhook

Payment Processor delivers payment updates by signing and POSTing events to your endpoint. Register an endpoint with the event types it should receive:

Request — POST /v1/webhook_endpoints
curl -X POST "$PAYMENT_PROCESSOR_API_URL/v1/webhook_endpoints" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://merchant.example/webhooks/payment-processor",
  "events": ["payment.created", "payment.received", "payment.settling", "payment.confirming", "payment.settled", "payment.failed", "payment.expired", "payment.canceled", "test.ping"]
}'

The events array is the subscription mechanism: it selects exactly which event types this endpoint receives. Omit it to subscribe to everything (the default is ["*"]); an empty array is rejected, and only HTTP(S) URLs are accepted. The full event-type table, delivery history, and replay live in Subscribe to events.

Response — 201 Created
{
  "id": "we_01K0PAYMENTPROCESSOR0000000000000000",
  "object": "webhook_endpoint",
  "url": "https://merchant.example/webhooks/payment-processor",
  "events": ["payment.created", "payment.received", "payment.settling", "payment.confirming", "payment.settled", "payment.failed", "payment.expired", "payment.canceled", "test.ping"],
  "status": "enabled",
  "created_at": "2026-07-20T12:00:00.000Z",
  "secret": "whsec_..."
}

The whsec_… secret is shown only on creation or rotation — store it like a password. test.ping is subscribed above so this guide’s final check creates a delivery for this endpoint; an endpoint subscribed only to payment event types still records the test event in the log but does not receive it.

One representative failure — sending "events": [] returns HTTP 400. Every other status and error contract is in the API reference.

Response — 400 Bad Request
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_events",
    "message": "At least one event must be selected.",
    "param": "events"
  }
}

What a delivery looks like

Each delivery is an HTTP POST with three headers:

Delivery headers
content-type: application/json
payment-processor-signature: t=<unix-seconds>,v1=<hmac-hex>
payment-processor-event-id: <event-id>

Verify signatures before parsing

The receiver below runs on Node 22 (ESM) with Express 5. It reads the exact raw body, accepts only a well-formed payment-processor-signature header, rejects timestamps skewed more than 300 seconds, compares digests in constant time, and only then parses JSON.

Terminal
npm install express
export PAYMENT_PROCESSOR_WEBHOOK_SECRET=whsec_... node server.mjs
server.mjs
// server.mjs — webhook receiver for Node 22 (ESM) + Express 5.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const secret = process.env.PAYMENT_PROCESSOR_WEBHOOK_SECRET;
if (!secret) throw new Error("Set PAYMENT_PROCESSOR_WEBHOOK_SECRET=whsec_... before starting.");
const app = express();
const MAX_SKEW_SECONDS = 300;
const SIGNATURE = /^t=([0-9]+),v1=([0-9a-f]{64})$/;

// Require the exact `t=,v1=` header format before parsing:
app.post("/webhooks/payment-processor", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.headers["payment-processor-signature"];
  const match = typeof header === "string" ? SIGNATURE.exec(header) : null;
  if (!match) return res.status(400).send("bad signature header");
  if (!Buffer.isBuffer(req.body)) return res.status(400).send("unexpected body");

  const [, timestamp, theirHex] = match;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > MAX_SKEW_SECONDS) {
    return res.status(400).send("timestamp out of tolerance");
  }

  // HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex-decoded and
  // compared as equal-length buffers in constant time.
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(req.body).digest();
  const received = Buffer.from(theirHex, "hex");
  if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
    return res.status(400).send("signature mismatch");
  }

  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.status(400).send("invalid JSON");
  }

  try {
    if (event.type === "payment.received") {
      // Replace with your fulfillment logic.
      console.log(`Payment received: ${event.data.object.id}`);
    }
    // Acknowledge every verified event type; unhandled branches must not retry.
    return res.status(204).end();
  } catch (error) {
    console.error("webhook handler failed", error);
    return res.status(500).end(); // Payment Processor retries this delivery
  }
});

app.listen(3000);

Confirm the endpoint works

POST /v1/webhook_endpoints/{id}/test queues a test.ping delivery at any time — the observable check that your receiver verifies and acknowledges deliveries. Endpoint update, secret rotation, and deletion are covered in the API reference.

Request — POST /v1/webhook_endpoints/{id}/test
curl -X POST "$PAYMENT_PROCESSOR_API_URL/v1/webhook_endpoints/we_01K0PAYMENTPROCESSOR000000000000000/test" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY"

Step 3

Create a payment QR

Create a payment intent from your server, then present the returned QR to the customer. Send the optional Idempotency-Key header so a retried request returns the original payment instead of creating a duplicate.

Request — POST /v1/payments
curl -X POST "$PAYMENT_PROCESSOR_API_URL/v1/payments" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234" \
  -d '{
  "amount": "12.50",
  "currency": "CASH",
  "description": "Order #1234",
  "reference": "ord_1234",
  "metadata": { "order_id": "ord_1234" },
  "display_name": "Acme Store",
  "expires_in": 900
}'

Request parameters

Field Rules
amount Required string, trimmed before validation. Unsigned decimal digits with an optional decimal point and one or two fractional digits — signs, exponent notation, and more than two fractional digits are rejected, never rounded. Accepted values render as Number(amount).toFixed(2), so "12" becomes "12.00". Must be greater than zero and at most 10000.00.
currency Required. CASH is the only accepted value.
description Optional, at most 500 characters.
reference Optional, at most 500 characters.
metadata Optional object, serialized with JSON.stringify; the serialized UTF-8 byte length must not exceed 4,096.
display_name Optional, at most 120 characters.
expires_in Optional integer seconds. Defaults to 900; must be an integer from 60 through 86,400.

The pending payment

A created payment answers with HTTP 201 and every field below.

Response — 201 Created
{
  "id": "pay01K0PAYMENTPROCESSOR0000000000000000",
  "object": "payment",
  "status": "pending",
  "amount": "12.50",
  "currency": "CASH",
  "description": "Order #1234",
  "reference": "ord_1234",
  "metadata": { "order_id": "ord_1234" },
  "display_name": "Acme Store",
  "payment_url": "https://api.example.com/pay/pay01K0PAYMENTPROCESSOR0000000000000000",
  "qr": {
    "deeplink": "polkadotapp://pay/cheque?id=pay01K0PAYMENTPROCESSOR0000000000000000&amount=12.50&key=PUBLIC_KEY&name=Acme%20Store",
    "png": "https://api.example.com/public/payments/pay01K0PAYMENTPROCESSOR0000000000000000/qr.png",
    "svg": "https://api.example.com/public/payments/pay01K0PAYMENTPROCESSOR0000000000000000/qr.svg"
  },
  "settlement": {
    "version": 1,
    "claim_tx_hash": null,
    "claim_tx_hashes": [],
    "claim_last_error": null,
    "claim_attempts": 0,
    "claim_max_attempts": 5,
    "claimed_at": null
  },
  "paid": null,
  "created_at": "2026-07-20T12:00:00.000Z",
  "expires_at": "2026-07-20T12:15:00.000Z",
  "received_at": null,
  "completed_at": null,
  "canceled_at": null
}

Present payment_url as a hosted checkout link, or embed the server-rendered qr.svg / qr.png directly. qr.deeplink opens the wallet app. Always render these returned assets — do not regenerate the deeplink or draw your own QR client-side.

Show the QR at checkout

Checkout page
<img id="payment-qr" width="240" height="240" alt="" />
<script type="module">
  // `payment` is the JSON your server received from POST /v1/payments.
  const qrImage = document.querySelector("#payment-qr");
  qrImage.src = payment.qr.svg;
  qrImage.alt = `Scan to pay ${payment.amount} ${payment.currency}`;
</script>

While checkout is open, poll the public status endpoint — it needs no Authorization header:

Request — GET /public/payments/{id}/status
curl "$PAYMENT_PROCESSOR_API_URL/public/payments/pay01K0PAYMENTPROCESSOR0000000000000000/status"

Step 4

Subscribe to events

Your subscription is the events array you configured in Register a webhook: each endpoint receives exactly the event types it lists, and "*" selects every supported type. There is no SSE or WebSocket stream — updates arrive as signed webhook deliveries, and GET /v1/events is the authenticated log of everything that was emitted.

Event What to do
payment.created Show the QR or deeplink to the payer; no funds have moved yet.
payment.received Fulfill the order once the signature is verified.
payment.settling Await settlement; do not treat as complete until payment.settled arrives.
payment.confirming Every coin transfer for the payment is in a block; funds are not final until payment.settled.
payment.settled Mark funds fully received; settlement.claim_tx_hashes is on the payment object.
payment.failed Cancel or retry the order; claim_last_error explains the failure.
payment.expired Stop presenting or accepting the checkout.
payment.canceled Release any reserved order state.
test.ping Verify endpoint connectivity; do not treat it as a payment.

Inspect history and replay deliveries

Terminal
curl "$PAYMENT_PROCESSOR_API_URL/v1/events?type=payment.received&limit=25" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY"

curl -X POST "$PAYMENT_PROCESSOR_API_URL/v1/events/evt_01K0PAYMENTPROCESSOR0000000000000000/resend" \
  -H "Authorization: Bearer $PAYMENT_PROCESSOR_API_KEY"

When has_more is true, take the id of the last item in data and pass it as starting_after on the next GET /v1/events call. limit accepts 1–100 and defaults to 25.

Each event in the authenticated log carries a deliveries array — one entry per subscribed endpoint:

Field Meaning
endpoint_id The webhook endpoint this delivery targets.
status pending, succeeded, or failed.
attempt_count Delivery attempts made so far.
last_status_code HTTP status of the most recent attempt; null until a response is received.
last_error Failure detail from the most recent attempt; null after success.
delivered_at When a 2xx response completed the delivery; null otherwise.

POST /v1/events/{id}/resend answers HTTP 202 with the same event envelope plus newly queued pending deliveries. Replaying an event that is missing — or that belongs to another merchant — returns event_not_found with HTTP 404.

Delivery retries

Every delivery gets seven total attempts: the initial POST is due immediately, and failures retry after 30s, 2m, 10m, 1h, 6h, and 24h. Any HTTP 2xx completes the delivery; a non-2xx response or the 10-second request timeout sends the attempt into that retry schedule.

What your receiver is handed

Webhook payload — relevant fields
{
  "id": "evt_01K0PAYMENTPROCESSOR0000000000000000",
  "object": "event",
  "type": "payment.received",
  "created_at": "2026-07-20T12:01:00.000Z",
  "data": {
    "object": {
      "id": "pay01K0PAYMENTPROCESSOR0000000000000000",
      "object": "payment",
      "status": "received",
      "amount": "12.50",
      "currency": "CASH"
    }
  }
}

This example is an excerpt: data.object is the complete Payment object documented in step 3, and the authenticated event-log response additionally contains deliveries.

Your integration loop is ready.

Send test.ping from the dashboard or POST /v1/webhook_endpoints/{id}/test, return a 2xx response from your handler, then confirm the event appears in GET /v1/events?type=test.ping.