After the payment
Webhooks
Receive signed events, verify them, handle retries and keep your endpoint healthy.
A webhook is an HTTP request that Raïs sends to your server when something happens to a payment or a refund. Use webhooks as your source of truth for results: they arrive even when the payer closes the browser before returning to your site.
Every webhook is signed, delivered at least once, and retried for about 20 hours if your server does not answer with a success status.
Set up an endpoint
In the dashboard, open Developers, then Webhooks, and add an endpoint:
- URL. Where Raïs sends events. It must be
https://for a live endpoint; a sandbox endpoint may usehttp://. It cannot contain a user name or password, and it must resolve to a public address:localhost, private networks and the like are refused. For a server on your computer, use a tunnel. - Events. All events (including the ones added later) or Selected events, such as
payment.succeededonly. - Store and environment. Chosen in the dialog: the store, then Test or Live (Live is offered for a store approved for live payments only). A test endpoint receives only test events, a live endpoint only live ones.
A store can have up to 20 endpoints in each environment.
The dashboard shows the endpoint's signing secret, which starts with whsec_. You need it to verify signatures. Treat it like a password.
Event types
| Type | Sent when |
|---|---|
payment.succeeded | The payer paid. data.object is the payment with status: "paid", its fees and its net. |
payment.failed | The bank declined the card (failure.code: payment_declined), or the payment ended in an error. A decline is final: that payment is never paid afterwards, the payer needs a new payment. |
payment.cancelled | You cancelled a pending payment (POST /payments/{id}/cancel). |
payment.reversed | SATIM reversed an authorisation after the payment. Treat the money as returned. |
refund.succeeded | A refund went through. data.object is the refund, with its fees. |
refund.failed | The bank refused a refund. data.object is the refund with its failure. |
New event types may be added over time. Ignore the ones you do not handle, but still answer them with a 2xx status.
Two things are not events. Nothing is sent when a payment expires: it simply reads as expired once expires_at has passed, so keep your own timer or retrieve the payment. And a payment can send payment.failed and, rarely, payment.succeeded afterwards, when the gateway later confirms a charge it first reported as an error: act on the latest event, or retrieve the payment.
The event
The body is a JSON object, the same one the events API returns. data.object is the complete payment or refund as it was when the event happened.
{
"id": "evt_Qm9dMhJ0k8s2Wv7Yc3XnLpAb",
"object": "event",
"api_version": "2026-10-26",
"created": 1793000300,
"livemode": false,
"type": "payment.succeeded",
"data": {
"object": {
"id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33",
"object": "payment",
"status": "paid",
"amount": 500000,
"currency": "DZD",
"description": "Order #1042",
"reference": "order-1042",
"customer": {
"id": "910158b6-8677-4d31-8779-919c4ea6428a",
"name": "Amina Benali",
"email": "amina@example.dz",
"phone": "+213550000000"
},
"items": [
{
"name": "Running shoes",
"description": null,
"quantity": 2,
"unit_amount": 250000
}
],
"metadata": {
"cart_id": "c_8812"
},
"livemode": false,
"payment_page": "rais",
"checkout_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33",
"success_url": "https://shop.example/thanks",
"cancel_url": "https://shop.example/cart",
"expires_at": 1793003600,
"created": 1793000000,
"paid_at": 1793000300,
"amount_refunded": 0,
"refunded": false,
"fees": {
"satim": 3500,
"rais": 1500,
"total": 5000
},
"net": 495000,
"receipt_url": "https://pay.rais.app/pay/5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33/receipt.pdf",
"failure": null,
"payment_method": {
"type": "card",
"brand": "dahabia",
"last4": "7215",
"masked": "628058**7215"
},
"processor": {
"name": "satim",
"order_id": "V721uPPfNNofVQAAABL3",
"approval_code": "913180",
"response_code": "00"
}
}
}
}idstringThe unique id of the event, starting with evt_. It never changes across retries: use it to de-duplicate.
objectstringAlways event.
api_versionstringThe API version the payload is rendered with.
createdintegerWhen the event happened, in Unix seconds.
livemodebooleanfalse for a sandbox event.
typestringWhat happened. See the table above.
In a payment.succeeded event, data.object.receipt_url is the payer's PDF receipt: a link that works in any browser without a key. See Receipts. data.object.fees and data.object.net say what the payment cost you and what you receive (see Pricing and fees); an event of a sandbox payment carries the simulated fees.
A refund.succeeded event carries the refund with its own fees, a flat fee whatever the amount (fees is null in a refund.failed event: a refund that failed costs nothing):
{
"id": "evt_Rt4kVx2Zq8Hn5Lw0Yc7PbMdE",
"object": "event",
"api_version": "2026-10-26",
"created": 1793000900,
"livemode": false,
"type": "refund.succeeded",
"data": {
"object": {
"id": "0c8a1d52-6e7b-4f0a-9b3d-7d2e4a8c5f10",
"object": "refund",
"payment_id": "5b1c2f4e-9d3a-4c61-8a7e-0f2d6b9e1a33",
"amount": 150000,
"currency": "DZD",
"status": "succeeded",
"reason": "Customer returned one item",
"failure": null,
"fees": {
"satim": 800,
"rais": 200,
"total": 1000
},
"livemode": false,
"created": 1793000900
}
}
}The body is compact JSON and pure ASCII: a character such as é arrives as \u00e9. This matters for the signature, which covers the exact bytes.
Headers
POST /webhooks/rais HTTP/1.1
Content-Type: application/json
User-Agent: Rais-Webhooks/1
Rais-Signature: t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c
Rais-Event-Id: evt_Qm9dMhJ0k8s2Wv7Yc3XnLpAb
Rais-Delivery-Id: 3f6e2d10-5a4b-4c7e-8d21-9b0c1e2f3a44
Rais-Attempt: 1
Rais-Livemode: false| Header | Value |
|---|---|
Rais-Signature | The signature: t=<unix seconds>,v1=<hex>. See below. |
Rais-Event-Id | The id of the event. Stable across retries and resends: de-duplicate on it. |
Rais-Delivery-Id | The id of this delivery. The same on every retry of it. |
Rais-Attempt | 1 for the first attempt, 2 for the first retry, and so on. |
Rais-Livemode | true or false. |
Verify the signature
Anyone who knows your endpoint address can send it a request. The signature proves that a request comes from Raïs and was not changed on the way. Verify it before you do anything else.
The header looks like t=1700000000,v1=ddb9.... To verify it:
- Take the timestamp
tand everyv1value from the header. - Compute the HMAC-SHA256, as lowercase hex, of the text
t, a dot, and the raw request body, with your signing secret (the completewhsec_...string) as the key. - Compare the result with each
v1value in constant time. One match is enough. - Reject the request when
tis more than 300 seconds away from your clock, in either direction. This stops an attacker from replaying a captured request later.
signed_payload = t + "." + raw_body
v1 = hex(HMAC_SHA256(key = signing secret, message = signed_payload))Each retry is signed again with a fresh timestamp, so a retry never fails the tolerance check because of its age.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* rawBody: the request body exactly as received (Buffer or string), never re-serialised JSON.
* header: the value of the Rais-Signature header.
*/
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
const pairs = header.split(",").map((part) => part.trim().split("="));
const timestamp = pairs.find(([key]) => key === "t")?.[1];
const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false;
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// Check it against the official test vector: this prints true.
const body = "{\"id\":\"evt_test_000000000000000000\",\"object\":\"event\",\"api_version\":\"2026-10-26\",\"created\":1700000000,\"livemode\":false,\"type\":\"payment.succeeded\",\"data\":{\"object\":{\"id\":\"pay_test_000000000000\",\"object\":\"payment\",\"amount\":150000,\"currency\":\"DZD\",\"status\":\"succeeded\"}}}";
console.log(verifySignature(body, "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300, 1700000000));Each program above ends with a check against the official test vector and prints true. Use the same vectors to test your own implementation. The secret is a fake one that exists only for this purpose, and the body is a short fixed example chosen for the check: a real event carries the complete payment object (a paid payment has the status paid).
| Secret | Timestamp | Expected header | |
|---|---|---|---|
| 1 | whsec_test_not_a_real_secret | 1700000000 | t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c |
| 2 | whsec_test_not_a_real_secret | 1700000123 | t=1700000123,v1=d5706b801c65362d38ec4bc85d7cc163780cb28914865873c7c9e30e73312b42 |
| 3 | whsec_test_not_a_real_secret and whsec_test_previous_secret | 1700000000 | t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c,v1=f579a5c29052f92cc26eb93acb1028e858b2e9e0980243792f4a09ef9a073f05 |
Vector 1 signs this body (it is not a realistic event: its ids are made up and its payment says status: succeeded, where a real payment says paid; only its bytes matter to the signature):
{"id":"evt_test_000000000000000000","object":"event","api_version":"2026-10-26","created":1700000000,"livemode":false,"type":"payment.succeeded","data":{"object":{"id":"pay_test_000000000000","object":"payment","amount":150000,"currency":"DZD","status":"succeeded"}}}Vector 2 signs a body with escapes (\u00e9) exactly as received. Vector 3 signs vector 1's body with two secrets, which is what a header looks like while a secret is being rotated: both signatures are present, and a receiver holding either secret matches one of them.
Getting the raw body
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on the webhook route; req.body is a Buffer |
| Next.js route handler | await request.text() |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI | await request.body() |
| Laravel and plain PHP | $request->getContent() or file_get_contents('php://input') |
| Go | io.ReadAll(r.Body) |
| Spring Boot | @RequestBody byte[] body |
| ASP.NET Core | Read Request.Body into a string or byte array before model binding |
Answer quickly
Answer with any 2xx status as soon as you have stored the event, and do slow work afterwards. Everything else is a failure and is retried:
- A
4xxor5xxstatus, a closed connection or no answer within 10 seconds. - A
3xxstatus. Raïs never follows redirects: configure the final address.
Raïs keeps the first kilobyte of your answer to show in the dashboard. It does not use it otherwise.
Retries
If an attempt fails, Raïs tries again, up to 8 attempts in all.
| Attempt | Sent |
|---|---|
| 1 | Right away |
| 2 | 30 seconds after attempt 1 failed |
| 3 | 2 minutes after attempt 2 failed |
| 4 | 10 minutes after attempt 3 failed |
| 5 | 30 minutes after attempt 4 failed |
| 6 | 2 hours after attempt 5 failed |
| 7 | 6 hours after attempt 6 failed |
| 8 | 12 hours after attempt 7 failed |
That covers about 20 hours and 43 minutes. When your server answers 429 or 503 with a Retry-After header, the next attempt waits at least that long, up to 1 hour. When every attempt fails, the delivery is marked failed; you can resend it from the dashboard once your endpoint is fixed.
Handle each event once
Delivery is at least once: the same event can reach you more than once, for example when your answer is lost. De-duplicate on the event id, and make the work and the record of it one atomic step:
// The dashboard's "Send test event" has an id that starts with evt_test_: acknowledge it, never fulfil an order for it.
if (event.id.startsWith("evt_test_")) return res.sendStatus(200);
await db.transaction(async (tx) => {
const first = await tx.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
[event.id],
);
if (first.rowCount === 0) return; // seen before: nothing to do
await fulfil(tx, event.data.object); // both happen, or neither
});
res.sendStatus(200);Events can also arrive out of order: a retry of an older event may come after a newer one. When the order matters, compare created, or retrieve the payment and act on its current status.
Keep the endpoint healthy
Raïs counts the deliveries that failed every attempt. After 20 in a row, it switches the endpoint off and records why. A success resets the counter. Fix your endpoint, then enable it again in the dashboard; events that happened meanwhile are not lost, you can read them with the events API for 30 days.
Watch the delivery log of the dashboard, and alert on failures, so that you notice a broken endpoint long before it is disabled.
Rotate the signing secret
Roll the secret from the dashboard when someone who knew it leaves, or you suspect a leak. For the next 24 hours Raïs signs each webhook with both the new and the previous secret, so the header carries two v1 values. A receiver that holds either secret verifies the request, which lets you deploy the new secret without missing an event. The code above already accepts any matching v1. After 24 hours only the new secret signs, so deploy it within that time.
Test your endpoint
- Send test event. The dashboard sends a fake
payment.succeededevent (its id starts withevt_test_, the payment is marked as a test and nothing is charged) to the endpoint. It gets one attempt, is never retried and does not count against the endpoint's health. It is signed like any other event. The fake payment does not exist: retrieving its id answers404. The event is listed byGET /eventslike the others, so skip the ids that start withevt_test_when you read them. - A sandbox payment. Pay a sandbox payment with a test card and watch a real event arrive.
- Failures. Answer
500once, and check that the retry reaches you withRais-Attempt: 2and the sameRais-Event-Id. - Resend. Press Resend next to any past delivery in the Deliveries list of the endpoint, for example after a fix.
Recognise the test event
The test event looks like a paid payment, and it is signed like a real one, so it will go through your signature check. It must never make your system fulfil an order. These fields identify it; any one of them is enough, and the first is the simplest:
{
"id": "evt_test_O0GCOwC2qTkEcypSFkt2V2t2",
"type": "payment.succeeded",
"data": {
"object": {
"status": "paid",
"amount": 10000,
"description": "Test payment sent by the 'Send test webhook' button. Nothing was charged.",
"reference": "rais_webhook_test",
"metadata": { "rais_test_event": "true" }
}
}
}- the event
idstarts withevt_test_; data.object.metadata.rais_test_eventis"true"anddata.object.referenceisrais_webhook_test;- the payment id in
data.object.idis made up: retrieving it answers404.
Its livemode is the one of the endpoint, so a test event sent to a live endpoint says livemode: true: do not use livemode to tell it apart. Acknowledge it with a 2xx and do nothing else.
Read events through the API
Events are also available for 30 days through GET /events, filtered by type and paginated. Use it to catch up after downtime.
curl 'https://api.rais.app/v1/events?type=payment.succeeded&limit=3' \
-H "Authorization: Bearer rais_sk_test_..."The legacy format
Stores that configured a webhook URL before API keys existed keep receiving the request they always did, unchanged, so existing integrations keep verifying it. New integrations should use the format described above.
| Legacy format | |
|---|---|
| Event header | X-Rais-Event: invoice.<status>, for example invoice.paid |
| Timestamp | X-Rais-Timestamp: <unix seconds> |
| Signature | X-Rais-Signature: sha256=<hex>, only when the store has a secret |
| Signed text | <timestamp>.<body>, HMAC-SHA256 with the store's secret |
| Body | The invoice, as compact JSON with the keys sorted |
| Also sent | Rais-Event-Id, Rais-Delivery-Id, Rais-Attempt and Rais-Livemode, as above |
Legacy endpoints receive payment.succeeded as invoice.paid, payment.failed as invoice.declined or invoice.error, payment.cancelled as invoice.cancelled, payment.reversed as invoice.reversed, and refund.succeeded as invoice.refunded or invoice.partially_refunded. refund.failed is not sent to them. Delivery, retries and health work as described above.