Webhooks
Receive 221 Pay events, verify the X-221-Signature header and handle redeliveries without duplicates.
221 Pay notifies the merchant's server whenever the outcome of a payment, refund, payout or dispute changes. A webhook is a signal: it triggers a read of the object, which is authoritative.
Add an endpoint
Dashboard, Developers > Webhooks (project administrator): enter the URL and tick the events.
https://URL reachable from the Internet: in production, nolocalhost, no private network address (400 INVALID_URLwhen adding, withfields.url).- At most 5 endpoints per project, one URL per endpoint.
- One signing secret per project (
whsec_…), shared by all its endpoints. Administrators see it in Developers > Webhooks, "Delivery signature" card: masked secret, "Show" and "Copy" buttons. The API returns it aswebhook_secretwithGET /v1/projets/{id}(administrator session).$PROJECT_ID: see Authentication, section "Project id".
On a development server (non-production), a local or private address
(http://localhost:3000/…, for example) is accepted, and only there. The
endpoint then carries local_only: true, the sign of an address that
production would refuse (http://, localhost, private network): its events
are delivered by the development server only. local_only appears on every
endpoint returned by GET /v1/projets/{id}/webhooks.
Through the API, with the project key (any scope, administrator role) or an administrator session:
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://shop.example/webhooks/221", "events": ["payment.succeeded", "payment.failed", "refund.succeeded"]}'| Route | Purpose |
|---|---|
GET /v1/projets/{id}/webhooks | List the endpoints. |
POST /v1/projets/{id}/webhooks | Add an endpoint: url and events, chosen from the catalogue. |
PATCH /v1/projets/{id}/webhooks/{subscriptionId} | Replace events. |
DELETE /v1/projets/{id}/webhooks/{subscriptionId} | Remove the endpoint (204). |
POST /v1/projets/{id}/webhooks/{subscriptionId}/test | Send a test event. |
Send a test event
To check the URL and the signature without waiting for a real payment,
POST /v1/projets/{id}/webhooks/{subscriptionId}/test sends a signed
test.ping event to the endpoint, like any other event (same secret, same
X-221-Timestamp and X-221-Signature). It carries the header
X-221-Event: test.ping in place of X-221-Event-Id: deduplicate on the id
field of the body. Permissions are those of PATCH: an administrator session, or a
key of the project. A key only reaches endpoints whose modes and events stay
within its own; otherwise 404.
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks/$SUBSCRIPTION_ID/test" \
-H "Authorization: Bearer $API_KEY"{
"id": "evt_…",
"type": "test.ping",
"created_at": "2026-10-07T10:00:00Z",
"livemode": false,
"data": {}
}livemode is true only for an endpoint that receives live mode alone.
test.ping is not in the catalogue: it cannot be subscribed to and is never
resent automatically.
{ "event": "test.ping", "livemode": false, "delivered": true, "status": 200 }delivered is true when the receiver answers 2xx. status is the
receiver's HTTP code, null if it did not answer; error then gives the
technical cause. Limit: 3 sends per minute per endpoint, then
429 RATE_LIMITED with Retry-After, in seconds.
Event catalogue
| Event | Trigger |
|---|---|
payment.succeeded | Payment confirmed, net amount credited. |
payment.failed | Payment failed. |
payment.expired | Payment expired. |
refund.requested | Refund recorded. |
refund.succeeded | Refund received by the customer. |
refund.failed | Refund declined, funds back in the balance. |
payout.succeeded | Payout paid. |
payout.failed | Payout declined, funds back in the balance. |
dispute.opened | Dispute opened on a payment. |
dispute.challenged | Dispute challenged by the merchant. |
dispute.accepted | Dispute accepted by the merchant. |
dispute.won | Dispute decided for the merchant. |
dispute.lost | Dispute decided for the customer. |
The catalogue is also served by GET /v1/webhook-event-types, without a key.
dataset.updated appears there: it belongs to the data API, with a different
body.
Request received
POST /webhooks/221 HTTP/1.1
Content-Type: application/json
X-221-Event-Id: evt_6a1f0b52-8e3c-4d7a-9f21-0c4e5b7d8a13
X-221-Timestamp: 1791367200
X-221-Signature: v1=5d41402abc4b2a76b9719d911017c592…
{"id":"evt_6a1f0b52-8e3c-4d7a-9f21-0c4e5b7d8a13","type":"payment.succeeded","created_at":"2026-10-07T10:00:03Z","object_type":"payment","object_id":"8d1f0c6e-3b7a-4c55-9a51-2f0e5c1d7a90","livemode":false}Every event has the same four fields:
| Field | Description |
|---|---|
id | Unique event id, identical on every redelivery: the deduplication key. |
type | Type, from the catalogue (plus test.ping). |
created_at | Event date. |
livemode | true for a live mode event, false in test mode. |
The rest depends on the type. Branch the processing on type, never on the
presence of a field.
| Events | Extra fields |
|---|---|
Payments (payment.*, refund.*, payout.*, dispute.*) | object_type (payment, refund, payout or dispute); object_id, the object id, to read with GET /v1/payments/{id}, /v1/refunds/{id}, /v1/payouts/{id} or /v1/disputes/{id}; failure_reason on payout.failed only: destination_limit, destination_invalid or provider_unavailable (absent on older payouts). |
test.ping and data API events (dataset.updated) | data, an object specific to the event (empty for test.ping). |
In payout.failed, failure_reason explains the failure:
failure_reason | Meaning |
|---|---|
destination_limit | The recipient wallet has reached its limit. |
destination_invalid | Recipient account or number refused by the operator. |
provider_unavailable | Payment service temporarily unavailable. |
The funds are back in the balance in all three cases. See Payouts.
Verify the signature
X-221-Signature is v1= followed by the hexadecimal HMAC-SHA256, computed
with the project secret over the string {X-221-Timestamp}.{raw body}.
- Read the raw body, before any JSON parsing.
- Reject a timestamp that is too old (5 minutes recommended): every delivery carries a fresh timestamp.
- Recompute the signature and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify221(rawBody, timestamp, signature, secret, toleranceSeconds = 300) {
if (!timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= toleranceSeconds)) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: the body must stay raw.
app.post('/webhooks/221', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify221(raw, req.get('X-221-Timestamp'), req.get('X-221-Signature'), process.env.WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
res.sendStatus(200);
// Then: skip an event.id already handled, and read the object event.object_id.
});Respond and process
- Answer
2xxwithin 5 seconds; process afterwards, outside the request. - A redirect (
3xx) is not followed and counts as a failure. - Deduplicate on
id: the same event can arrive more than once. - Read the object before acting: events can arrive out of order.
Redeliveries
Without a 2xx answer, the event is resent 30 seconds, 2 minutes,
10 minutes, 1 hour then 24 hours after the previous attempt, with a small
random offset: 6 attempts in total. An event is marked delivered only once
every subscribed endpoint answered 2xx; otherwise it is resent to all of
them, hence the deduplication.
Dashboard, Developers > Webhooks: event history, detail of each attempt (status, response body) and manual resend (user or administrator role).