Skip to content

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, no localhost, no private network address (400 INVALID_URL when adding, with fields.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 as webhook_secret with GET /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:

Terminal
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"]}'
RoutePurpose
GET /v1/projets/{id}/webhooksList the endpoints.
POST /v1/projets/{id}/webhooksAdd 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}/testSend 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.

Terminal
curl -X POST "$API_URL/v1/projets/$PROJECT_ID/webhooks/$SUBSCRIPTION_ID/test" \
  -H "Authorization: Bearer $API_KEY"
Body sent to the endpoint
{
  "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.

200 · application/json
{ "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

EventTrigger
payment.succeededPayment confirmed, net amount credited.
payment.failedPayment failed.
payment.expiredPayment expired.
refund.requestedRefund recorded.
refund.succeededRefund received by the customer.
refund.failedRefund declined, funds back in the balance.
payout.succeededPayout paid.
payout.failedPayout declined, funds back in the balance.
dispute.openedDispute opened on a payment.
dispute.challengedDispute challenged by the merchant.
dispute.acceptedDispute accepted by the merchant.
dispute.wonDispute decided for the merchant.
dispute.lostDispute 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

Request
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:

FieldDescription
idUnique event id, identical on every redelivery: the deduplication key.
typeType, from the catalogue (plus test.ping).
created_atEvent date.
livemodetrue 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.

EventsExtra 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_reasonMeaning
destination_limitThe recipient wallet has reached its limit.
destination_invalidRecipient account or number refused by the operator.
provider_unavailablePayment 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}.

  1. Read the raw body, before any JSON parsing.
  2. Reject a timestamp that is too old (5 minutes recommended): every delivery carries a fresh timestamp.
  3. Recompute the signature and compare in constant time.
JavaScript
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 2xx within 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).