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
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.
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"}'
{
"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:
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.
{
"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.
{
"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:
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.
npm install express
export PAYMENT_PROCESSOR_WEBHOOK_SECRET=whsec_... node 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.
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.
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.
{
"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
<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:
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
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
{
"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.