Skip to content

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 use http://. 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.succeeded only.
  • 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

TypeSent when
payment.succeededThe payer paid. data.object is the payment with status: "paid", its fees and its net.
payment.failedThe 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.cancelledYou cancelled a pending payment (POST /payments/{id}/cancel).
payment.reversedSATIM reversed an authorisation after the payment. Treat the money as returned.
refund.succeededA refund went through. data.object is the refund, with its fees.
refund.failedThe 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"
      }
    }
  }
}
idstring

The unique id of the event, starting with evt_. It never changes across retries: use it to de-duplicate.

objectstring

Always event.

api_versionstring

The API version the payload is rendered with.

createdinteger

When the event happened, in Unix seconds.

livemodeboolean

false for a sandbox event.

typestring

What happened. See the table above.

data.objectobject

The payment or refund the event is about.

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
HeaderValue
Rais-SignatureThe signature: t=<unix seconds>,v1=<hex>. See below.
Rais-Event-IdThe id of the event. Stable across retries and resends: de-duplicate on it.
Rais-Delivery-IdThe id of this delivery. The same on every retry of it.
Rais-Attempt1 for the first attempt, 2 for the first retry, and so on.
Rais-Livemodetrue 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:

  1. Take the timestamp t and every v1 value from the header.
  2. Compute the HMAC-SHA256, as lowercase hex, of the text t, a dot, and the raw request body, with your signing secret (the complete whsec_... string) as the key.
  3. Compare the result with each v1 value in constant time. One match is enough.
  4. Reject the request when t is 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).

SecretTimestampExpected header
1whsec_test_not_a_real_secret1700000000t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c
2whsec_test_not_a_real_secret1700000123t=1700000123,v1=d5706b801c65362d38ec4bc85d7cc163780cb28914865873c7c9e30e73312b42
3whsec_test_not_a_real_secret and whsec_test_previous_secret1700000000t=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

FrameworkRaw body
Expressexpress.raw({ type: "application/json" }) on the webhook route; req.body is a Buffer
Next.js route handlerawait request.text()
Flaskrequest.get_data()
Djangorequest.body
FastAPIawait request.body()
Laravel and plain PHP$request->getContent() or file_get_contents('php://input')
Goio.ReadAll(r.Body)
Spring Boot@RequestBody byte[] body
ASP.NET CoreRead 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 4xx or 5xx status, a closed connection or no answer within 10 seconds.
  • A 3xx status. 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.

AttemptSent
1Right away
230 seconds after attempt 1 failed
32 minutes after attempt 2 failed
410 minutes after attempt 3 failed
530 minutes after attempt 4 failed
62 hours after attempt 5 failed
76 hours after attempt 6 failed
812 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.succeeded event (its id starts with evt_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 answers 404. The event is listed by GET /events like the others, so skip the ids that start with evt_test_ when you read them.
  • A sandbox payment. Pay a sandbox payment with a test card and watch a real event arrive.
  • Failures. Answer 500 once, and check that the retry reaches you with Rais-Attempt: 2 and the same Rais-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 id starts with evt_test_;
  • data.object.metadata.rais_test_event is "true" and data.object.reference is rais_webhook_test;
  • the payment id in data.object.id is made up: retrieving it answers 404.

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 headerX-Rais-Event: invoice.<status>, for example invoice.paid
TimestampX-Rais-Timestamp: <unix seconds>
SignatureX-Rais-Signature: sha256=<hex>, only when the store has a secret
Signed text<timestamp>.<body>, HMAC-SHA256 with the store's secret
BodyThe invoice, as compact JSON with the keys sorted
Also sentRais-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.