Implementation Guide
Process payment webhooks safely
Verify, deduplicate and act on payment events without turning a webhook into a second financial action.
The rule to remember: a webhook is evidence of an event, not a reason to invent a second financial action. Verify it, store it once, answer quickly, and read the payment's current status before you change anything on your side.
The job
A shop in Colombia takes PSE bank-transfer payments. The payer leaves for their bank, approves the transfer, and may or may not come back to the shop's return page. The order should be marked paid exactly once, when the payment is really complete, and never because of a forged or replayed request.
Orangepill tells your server when a payment reaches a final state by posting a signed event to your callback URL. This guide covers receiving those events so that duplicates, retries, late arrivals and lost deliveries do not change the result.
Before you start
- An API key for the sandbox. Sandbox access is provided during evaluation.
- A public HTTPS endpoint that can receive
POSTrequests and read the raw request body. - A secret for signing, stored like an API key. Without a secret, deliveries are sent unsigned.
- A table or queue where you can store events durably before processing them.
Webhooks exist for pay-ins and checkout sessions. There are no payout webhooks: payout status is read with
GET /v4/payouts/{payoutId}.
Flow
- Receive Raw body + headers
- Verify HMAC + timestamp
- Store once Unique on event id
- Acknowledge 2xx within 10 s
- Process Read current status, then act
Implementation
1. Register a callback
For a single payment, add a callback object when you create it with
POST /v4/checkout/payments. url is required. events defaults to both
payment events. Set a secret so every delivery is signed.
curl -X POST "$ORANGEPILL_API/v4/checkout/payments" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "12000000",
"currency": "COP",
"product_key": "bank_transfer.pse",
"channel_type": "redirect",
"return_url": "https://shop.example.com/orders/A-1042/return",
"metadata": { "order_ref": "A-1042" },
"callback": {
"url": "https://api.example.com/webhooks/orangepill",
"events": ["payment.succeeded", "payment.failed"],
"secret": "<long random secret>"
}
}' PSE may also need payer details, which depend on the provider. See Accept a local bank-transfer payment.
For a hosted checkout session, register the callback on the session:
curl -X POST "$ORANGEPILL_API/v4/checkout/sessions/<session id>/callback" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/orangepill",
"events": ["checkout.session.completed", "checkout.session.expired", "checkout.session.cancelled"],
"secret": "<long random secret>"
}' These are the events Orangepill emits today:
payment.succeededandpayment.failed, for a payment's callback.checkout.session.completed,checkout.session.expiredandcheckout.session.cancelled, for a session's callback.checkout.session.failed, which goes to integration-level webhooks, not to the per-session callback.
Payment events fire only when a payment reaches completed or failed. Intermediate states are not sent.
2. Know what arrives
POST /webhooks/orangepill
Content-Type: application/json
X-Orangepill-Event: payment.succeeded
X-Orangepill-Event-Id: 3f6c1b9e-…
X-Orangepill-Delivery-Id: 9a02e4d1-…
X-Orangepill-Timestamp: 1791731062
X-Orangepill-Signature: sha256=5d1f…
{
"id": "3f6c1b9e-…",
"event": "payment.succeeded",
"version": "2026-03-25.v1",
"timestamp": "2026-10-11T15:04:22.481Z",
"data": {
"payment_id": "b81d07c2-…",
"amount": "<amount>",
"currency": "COP",
"status": "completed",
"method": "<payment method key>",
"provider": "<provider>",
"order_id": null,
"customer_id": null
},
"meta": { "correlation_id": null }
} X-Orangepill-Event-Id and the body's id are the same value and stay the same across
retries. The body is identical on every attempt. Each attempt is signed again with a fresh
X-Orangepill-Timestamp, so the signature changes between attempts while the event does not.
Use amount and currency to cross-check against your own order, not as the instruction
for what to credit.
3. Verify the signature on the raw body
The signature is sha256= followed by the hex HMAC-SHA256 of
{timestamp}.{raw body}, keyed with your secret. Compute it over the bytes you
received. If your framework parses the JSON first and you serialize it again, key order or whitespace can change
and valid events will fail verification.
Reject timestamps more than 300 seconds from your clock, which limits replay of captured requests. Compare signatures with a constant-time function.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.ORANGEPILL_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function isAuthentic(rawBody, headers) {
const signature = headers['x-orangepill-signature'] ?? '';
const timestamp = headers['x-orangepill-timestamp'] ?? '';
if (!signature.startsWith('sha256=') || !/^\d+$/.test(timestamp)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody) // the exact bytes you received, never re-serialized JSON
.digest();
const received = Buffer.from(signature.slice('sha256='.length), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the body as a Buffer. Do not put express.json() in front of this route.
app.post('/webhooks/orangepill', express.raw({ type: 'application/json' }), async (req, res) => {
if (!isAuthentic(req.body, req.headers)) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8')); // parse only after verifying
try {
await saveToInbox(event); // durable, deduplicated (next step)
} catch (err) {
return res.status(503).end(); // your storage failed: ask Orangepill to retry
}
res.status(200).end(); // acknowledge fast; a worker processes the inbox
});
The status code you return matters. 2xx ends delivery. 408, 429 and
5xx are retried. Any other 4xx, including the 401 above, stops retries for
that delivery. Return 503 when your own storage is down, so the event comes back.
4. Deduplicate on the event id
Delivery is at least once. The same event can arrive more than once, for example when your 200 was
sent but did not reach Orangepill. A unique constraint on the event id turns a redelivery into a no-op.
CREATE TABLE orangepill_webhook_inbox (
event_id text PRIMARY KEY, -- X-Orangepill-Event-Id, same as body "id"
event_type text NOT NULL,
payment_id text,
body jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
); // Returns true the first time an event is seen, false for a redelivery.
async function saveToInbox(event) {
const result = await db.query(
`INSERT INTO orangepill_webhook_inbox (event_id, event_type, payment_id, body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.event, event.data?.payment_id ?? null, event],
);
return result.rowCount === 1;
}
Answer 200 for a duplicate too. It is a correct delivery of an event you already have.
The event id protects you from receiving the same event twice. Your business action needs its own guard: key it
on payment_id, so marking an order paid runs once per payment whatever arrives.
5. Acknowledge fast, process later
Orangepill waits 10 seconds for your response. A slow handler gets retried even if it eventually succeeds. Store
the event, return 200, and do the work in a worker.
Failed deliveries are retried immediately, then after 10 seconds, 60 seconds and 5 minutes: four attempts in total. After that the delivery is dead-lettered and not sent again.
6. Read current state before you act
There is no ordering guarantee. Events can arrive late or out of order, and a dead-lettered event never arrives. So the worker treats the event as a prompt to check, and reads the payment's status before changing anything.
// Runs in a worker, outside the HTTP request.
async function processPaymentEvent(event) {
const paymentId = event.data.payment_id;
// The event tells you something happened. Read the current state before acting on it.
const res = await fetch(`${API}/v4/payments/${paymentId}/status`, { headers: auth });
const { status } = await res.json();
if (status === 'completed') {
await markOrderPaid(paymentId); // idempotent per payment_id: a no-op if already paid
} else if (status === 'failed') {
await markPaymentFailed(paymentId); // record it; do not create a new payment from here
}
// Any other status is not final. Leave the order as it is and let the reconciler check later.
await markProcessed(event.id);
} curl "$ORANGEPILL_API/v4/payments/<payment id>/status" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY"
{ "status": "completed" }
The same read is your fallback when no event comes. Run a small reconciler that checks payments still open on your
side after the time you expect for that rail, and settles them from GET /v4/payments/{paymentId}/status.
7. Debug with delivery history
Orangepill records every delivery: status, attempts and errors. Use it to answer "did you send it?" before changing code.
curl "$ORANGEPILL_API/v4/checkout/payments/<payment id>/callback-deliveries" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY"
curl "$ORANGEPILL_API/v4/checkout/sessions/<session id>/callback-deliveries" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY"
curl "$ORANGEPILL_API/v4/checkout/webhook-deliveries/<X-Orangepill-Delivery-Id>" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" What can go wrong
- Signature checks fail on valid events. Usually the body was parsed and re-serialized, or a proxy changed it. Verify against the raw bytes.
- The same event arrives twice. Expected. The unique insert makes the second one a no-op.
- Your handler is slow or calls other services inline. Orangepill times out after 10 seconds and retries, and you may end up processing the same event twice at once. Store, acknowledge, process later.
- A
payment.failedtriggers a new charge. That is the second financial action this guide warns about. Record the failure and let the payer or your product decide what to do next, as a new payment. - The delivery is dead-lettered. Four attempts failed, or you returned a non-retryable
4xx. The event will not come back. Your reconciler picks up the payment from its current status. - Your clock drifts. Valid events fall outside the 300-second window. Keep servers on NTP.
What Orangepill guarantees today
- When a callback has a secret, every delivery carries
X-Orangepill-Timestampand an HMAC-SHA256X-Orangepill-Signatureover the timestamp and raw body. - The event id and body stay the same across retries of a delivery.
- Failed deliveries are retried up to four attempts in total on timeouts,
408,429and5xx. - Every delivery and its attempts can be inspected through the delivery history routes.
GET /v4/payments/{paymentId}/statusreturns the current status whether or not a webhook arrived.
What Orangepill does not guarantee
- Exactly-once delivery. Delivery is at least once, so duplicates are normal.
- Ordering. Events can arrive in any order.
- Delivery after the last retry. A dead-lettered event is not resent automatically.
- Payout webhooks. There are none today.
- Signatures on callbacks registered without a secret. Those deliveries are unsigned.
Production considerations
- Production uses its own API key, its own callback URLs and its own secrets. Nothing carries over from sandbox.
- Always set a secret, keep it out of source control, and plan how you will rotate it.
- Persist every verified event before acknowledging, with the event id as a unique key.
- Monitor signature failures, handler latency (keep it far below 10 seconds) and the age of unprocessed inbox rows.
- Run the reconciler for open payments, so a lost event delays your order update but does not change its result.
- Test in sandbox: a duplicate delivery, a bad signature, an old timestamp, a slow handler, and a handler that returns
500. - Your support team should be able to see, for an order, the payment status and its delivery history.
The full list is in the production checklist.
Build this with us.
Sandbox access and API credentials are provided during evaluation.