Implementation Guide

Process payment webhooks safely

Verify, deduplicate and act on payment events without turning a webhook into a second financial action.

10 min to implement · Verified against the v4 API on 2026-10-11

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 POST requests 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

  1. Receive Raw body + headers
  2. Verify HMAC + timestamp
  3. Store once Unique on event id
  4. Acknowledge 2xx within 10 s
  5. 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.

Create a payment with a callback
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:

Register a session callback
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.succeeded and payment.failed, for a payment's callback.
  • checkout.session.completed, checkout.session.expired and checkout.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

A payment.succeeded delivery
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.

Verify and acknowledge (Node.js, Express)
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.

Inbox table (SQL)
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
);
Insert once (JavaScript)
// 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.

Process an event (JavaScript)
// 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);
}
Read the current status
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.

Delivery history
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.failed triggers 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-Timestamp and an HMAC-SHA256 X-Orangepill-Signature over 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, 429 and 5xx.
  • Every delivery and its attempts can be inspected through the delivery history routes.
  • GET /v4/payments/{paymentId}/status returns 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.