# Webhooks

> Receive signed events, verify them, handle retries and keep your endpoint healthy.

Source: https://rais.app/docs/webhooks

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

| 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](https://rais.app/docs/api/retrieve-event.md) returns. `data.object` is the complete payment or refund as it was when the event happened.

~~~json
{
  "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"
      }
    }
  }
}
~~~


- `id` (string): the unique id of the event, starting with `evt_`. It never changes across retries: use it to de-duplicate.
- `object` (string): always `event`.
- `api_version` (string): the API version the payload is rendered with.
- `created` (integer): when the event happened, in Unix seconds.
- `livemode` (boolean): `false` for a sandbox event.
- `type` (string): what happened. See the table above.
- `data.object` (object): the [payment](https://rais.app/docs/api/objects/payment.md) or [refund](https://rais.app/docs/api/objects/refund.md) 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](https://rais.app/docs/receipts.md). `data.object.fees` and `data.object.net` say what the payment cost you and what you receive (see [Pricing and fees](https://rais.app/docs/pricing.md)); 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):

~~~json
{
  "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

```http
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:

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.

~~~text
signed_payload = t + "." + raw_body
v1             = hex(HMAC_SHA256(key = signing secret, message = signed_payload))
~~~


> [!WARNING]
> **Sign the raw body, not parsed JSON**
>
> The signature covers the exact bytes Raïs sent. If your framework parses the JSON first and you serialise it again, spacing and key order change and the signature no longer matches. Read the body as bytes, verify, and only then parse it.


Each retry is signed again with a fresh timestamp, so a retry never fails the tolerance check because of its age.


**Verify a Rais-Signature header**

**Node.js**

```javascript
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));
```

**Python**

```python
import hashlib
import hmac
import time


def verify_signature(raw_body, header, secret, tolerance=300, now=None):
    """raw_body: the request body exactly as received (bytes). header: the Rais-Signature header."""
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not timestamp.isascii() or not timestamp.isdigit() or not signatures:
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
    return any(hmac.compare_digest(expected, signature.lower().encode()) for signature in signatures)


# Check it against the official test vector: this prints True.
body = b'{"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"}}}'
print(verify_signature(body, "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300, 1700000000))
```

**PHP**

```php
<?php

function verifySignature(string $rawBody, string $header, string $secret, int $tolerance = 300, ?int $now = null): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    $valid = false;
    foreach ($signatures as $signature) {
        $valid = hash_equals($expected, strtolower($signature)) || $valid;
    }
    return $valid;
}

// Check it against the official test vector: this prints bool(true).
$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"}}}';
var_dump(verifySignature($body, 't=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c', 'whsec_test_not_a_real_secret', 300, 1700000000));
```

**Go**

```go
package main

import (
	"fmt"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"strconv"
	"strings"
	"time"
)

// VerifySignature checks a Rais-Signature header against the raw request body.
func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration, now time.Time) bool {
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signatures = append(signatures, value)
		}
	}
	if timestamp == "" || strings.Trim(timestamp, "0123456789") != "" || len(signatures) == 0 {
		return false
	}
	seconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(now.Unix()-seconds)) > tolerance.Seconds() {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	valid := false
	for _, signature := range signatures {
		if received, err := hex.DecodeString(signature); err == nil && hmac.Equal(received, expected) {
			valid = true
		}
	}
	return valid
}

func main() {
	// Check it against the official test vector: this prints true.
	body := []byte(`{"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"}}}`)
	now := time.Unix(1700000000, 0)
	fmt.Println(VerifySignature(body, "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300*time.Second, now))
}
```

**Java**

```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class RaisSignature {
    public static boolean verify(byte[] rawBody, String header, String secret, long toleranceSeconds, long nowSeconds)
            throws Exception {
        String timestamp = null;
        List<String> signatures = new ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length != 2) continue;
            if (pair[0].equals("t")) timestamp = pair[1];
            else if (pair[0].equals("v1")) signatures.add(pair[1]);
        }
        if (timestamp == null || !timestamp.matches("[0-9]{1,18}") || signatures.isEmpty()) return false;
        if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > toleranceSeconds) return false;

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.US_ASCII));
        StringBuilder expected = new StringBuilder();
        for (byte b : mac.doFinal(rawBody)) expected.append(String.format("%02x", b));

        boolean valid = false;
        for (String signature : signatures) {
            valid |= MessageDigest.isEqual(
                expected.toString().getBytes(StandardCharsets.US_ASCII),
                signature.toLowerCase().getBytes(StandardCharsets.US_ASCII));
        }
        return valid;
    }

    public static void main(String[] args) throws Exception {
        // Check it against the official test vector: this prints true.
        String 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\"}}}";
        System.out.println(verify(body.getBytes(StandardCharsets.UTF_8), "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300, 1700000000L));
    }
}
```

**C#**

```csharp
using System.Security.Cryptography;
using System.Text;

static bool VerifySignature(byte[] rawBody, string header, string secret, int toleranceSeconds = 300, long? nowSeconds = null)
{
    string? timestamp = null;
    var signatures = new List<string>();
    foreach (var part in header.Split(','))
    {
        var pair = part.Trim().Split('=', 2);
        if (pair.Length != 2) continue;
        if (pair[0] == "t") timestamp = pair[1];
        else if (pair[0] == "v1") signatures.Add(pair[1]);
    }
    if (timestamp is null || timestamp.Length is 0 or > 18 || !timestamp.All(c => c is >= '0' and <= '9') || signatures.Count == 0) return false;
    var now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(now - long.Parse(timestamp)) > toleranceSeconds) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var signed = Encoding.ASCII.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
    var expected = hmac.ComputeHash(signed);

    var valid = false;
    foreach (var signature in signatures)
    {
        try { valid |= CryptographicOperations.FixedTimeEquals(Convert.FromHexString(signature), expected); }
        catch (FormatException) { }
    }
    return valid;
}

// Check it against the official test vector: this prints True.
var 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.WriteLine(VerifySignature(Encoding.UTF8.GetBytes(body), "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300, 1700000000));
```

**Swift**

```swift
import CryptoKit
import Foundation

func verifySignature(rawBody: Data, header: String, secret: String, tolerance: TimeInterval = 300, now: Date = Date()) -> Bool {
    var timestamp: String?
    var signatures: [String] = []
    for part in header.split(separator: ",") {
        let pair = part.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1).map(String.init)
        guard pair.count == 2 else { continue }
        if pair[0] == "t" { timestamp = pair[1] } else if pair[0] == "v1" { signatures.append(pair[1]) }
    }
    guard let timestamp, !timestamp.isEmpty, timestamp.allSatisfy({ $0.isASCII && $0.isNumber }),
          let seconds = TimeInterval(timestamp), !signatures.isEmpty,
          abs(now.timeIntervalSince1970 - seconds) <= tolerance else { return false }

    var signed = Data("\(timestamp).".utf8)
    signed.append(rawBody)
    let key = SymmetricKey(data: Data(secret.utf8))
    return signatures.contains { signature in
        guard let received = hexData(signature) else { return false }
        return HMAC<SHA256>.isValidAuthenticationCode(received, authenticating: signed, using: key)
    }
}

func hexData(_ hex: String) -> Data? {
    guard hex.count % 2 == 0 else { return nil }
    var data = Data()
    var index = hex.startIndex
    while index < hex.endIndex {
        let next = hex.index(index, offsetBy: 2)
        guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }
        data.append(byte)
        index = next
    }
    return data
}

// Check it against the official test vector: this prints true.
let body = Data(#"{"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"}}}"#.utf8)
print(verifySignature(rawBody: body, header: "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", secret: "whsec_test_not_a_real_secret", now: Date(timeIntervalSince1970: 1700000000)))
```

**Kotlin**

```kotlin
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs

fun verifySignature(
    rawBody: ByteArray,
    header: String,
    secret: String,
    toleranceSeconds: Long = 300,
    nowSeconds: Long = System.currentTimeMillis() / 1000,
): Boolean {
    var timestamp: String? = null
    val signatures = mutableListOf<String>()
    for (part in header.split(",")) {
        val pair = part.trim().split("=", limit = 2)
        if (pair.size != 2) continue
        when (pair[0]) {
            "t" -> timestamp = pair[1]
            "v1" -> signatures.add(pair[1])
        }
    }
    val sent = timestamp ?: return false
    if (sent.isEmpty() || sent.length > 18 || !sent.all { it in '0'..'9' } || signatures.isEmpty()) return false
    if (abs(nowSeconds - sent.toLong()) > toleranceSeconds) return false

    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
    mac.update("$sent.".toByteArray())
    val expected = mac.doFinal(rawBody).joinToString("") { "%02x".format(it) }

    var valid = false
    for (signature in signatures) {
        valid = valid or MessageDigest.isEqual(expected.toByteArray(), signature.lowercase().toByteArray())
    }
    return valid
}

fun main() {
    // Check it against the official test vector: this prints true.
    val 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\"}}}"
    println(verifySignature(body.toByteArray(), "t=1700000000,v1=ddb9f8a187313afb17555011222152be4f076bf39218f86a28f0a93714651a6c", "whsec_test_not_a_real_secret", 300, 1700000000L))
}
```


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

~~~text
{"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 `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.

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

~~~javascript
// 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](https://rais.app/docs/api/list-events.md) 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](https://rais.app/docs/testing.md) 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:

~~~json
{
  "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.


**Request**

```bash
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.


> [!NOTE]
> **Verifying a legacy request**
>
> Compute `hex(HMAC_SHA256(secret, timestamp + "." + body))` over the raw body and compare it with the value after `sha256=`, in constant time, exactly as for the current format. Keep the timestamp check too.
