# Webhooks

> Have Kosoor push ZATCA results to your system as they arrive, verify each request, and handle retries and duplicates.

A sales invoice or credit note that answers `zatca.status: "pending"` gets its ZATCA result later. Instead of polling for it, register a webhook endpoint: Kosoor sends your system an HTTPS `POST` the moment the result is in.

Kosoor follows [Standard Webhooks](https://www.standardwebhooks.com/), so the open-source `standardwebhooks` libraries verify its requests as they are.

## Register an endpoint

```bash
curl https://api.kosoor.com.sa/v1/webhook-endpoints \
  -H "Authorization: Bearer ksr_live_..." \
  -H "Idempotency-Key: 6f1d2c7e-hooks-erp" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://erp.example.com/kosoor/webhooks","events":["salesInvoice.zatcaCleared","salesInvoice.zatcaReported","salesInvoice.zatcaRejected"]}'
```

```json
{
  "id": "4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63",
  "object": "webhookEndpoint",
  "url": "https://erp.example.com/kosoor/webhooks",
  "events": ["salesInvoice.zatcaCleared", "salesInvoice.zatcaReported", "salesInvoice.zatcaRejected"],
  "status": "enabled",
  "disabledReason": null,
  "secret": "whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w=",
  "createdAt": "2026-11-07T09:30:00Z"
}
```

**Store `secret` now.** It appears only in this reply and in the reply to a secret rotation; you need it to verify every request.

- The key needs `webhooks:write` to manage endpoints (`webhooks:read` to list them), and read access to what the events are about: `sales_invoices:read` for `salesInvoice.*`, `credit_notes:read` for `creditNote.*`.
- An endpoint belongs to the key that created it. Other keys do not see it, and it stops receiving when that key is revoked or expires. A key limited to some branches only receives events for documents in those branches.
- A test key's endpoints live in the sandbox and receive only sandbox events. They survive a sandbox reset.
- The URL must be `https`, without a user name or password, and its host must resolve to a public address.
- A key holds at most 10 endpoints; the 11th answers `422 webhook_endpoint_limit`.

| | |
|---|---|
| `GET /v1/webhook-endpoints` | list the key's endpoints (no secrets) |
| `GET /v1/webhook-endpoints/{id}` | read one |
| `DELETE /v1/webhook-endpoints/{id}` | remove it; anything still waiting for it is cancelled |
| `POST /v1/webhook-endpoints/{id}/rotate-secret` | issue a new secret (see [Rotating the secret](#rotating-the-secret)) |
| `POST /v1/webhook-endpoints/{id}/test` | send a `webhookEndpoint.test` event now |

Every `POST` and `DELETE` carries an `Idempotency-Key`, as on the rest of the API.

## Events

| Event | Sent when |
|---|---|
| `salesInvoice.zatcaCleared` | ZATCA cleared a standard (B2B) invoice that had answered `pending` |
| `salesInvoice.zatcaReported` | ZATCA accepted the report of a simplified (B2C) invoice |
| `salesInvoice.zatcaRejected` | ZATCA rejected the invoice |
| `creditNote.zatcaCleared` | the same three, for a credit note |
| `creditNote.zatcaReported` | |
| `creditNote.zatcaRejected` | |
| `webhookEndpoint.test` | you called `POST /v1/webhook-endpoints/{id}/test`; sent whatever the endpoint subscribes to |

A result the creating call already returned (a standard invoice cleared at issue) raises no event: you have it in the reply.

## What Kosoor sends

```http
POST /kosoor/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Kosoor-Webhooks/1.0
webhook-id: 019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74
webhook-timestamp: 1794043800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json
{
  "id": "019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74",
  "object": "event",
  "type": "salesInvoice.zatcaReported",
  "createdAt": "2026-11-07T09:31:12Z",
  "data": {
    "id": "5c8a1f9e-2d7b-4c3a-9e6f-1a4b8d0c7e52",
    "object": "salesInvoice",
    "number": "INV-10045",
    "referenceId": "order-10045",
    "zatca": {
      "kind": "simplified",
      "status": "reported",
      "uuid": "b7c1e0d2-4f3a-4c8e-9a1b-2d5e6f7a8b9c",
      "counter": 1287,
      "hash": "...",
      "qr": "...",
      "submittedAt": "2026-11-07T09:31:10Z",
      "clearedAt": null,
      "reportedAt": "2026-11-07T09:31:12Z",
      "errors": []
    }
  }
}
```

- `data` names the document and carries its `zatca` block, the same block `GET` returns. Read the whole document with `GET /v1/sales-invoices/{id}` or `GET /v1/credit-notes/{id}` when you need more.
- `id` is the event's id. It equals the `webhook-id` header and stays the same on every retry.
- `createdAt` is when the event was raised, in UTC; `webhook-timestamp` is when this attempt was sent, in Unix seconds.

## Verify every request

Anyone can `POST` to your URL. Check the signature before you trust the body, and check it against the **raw body bytes**: a JSON parser that re-serializes the body changes them.

The signature is HMAC-SHA256, keyed by the secret's bytes (the part after `whsec_`, base64-decoded), over `webhook-id`, `webhook-timestamp` and the body joined by dots. `webhook-signature` holds one or more `v1,<base64>` values separated by spaces; accept the request when any of them matches. Refuse a `webhook-timestamp` more than 5 minutes from your clock, so a captured request cannot be replayed later.

### Node.js

```bash
npm install standardwebhooks
```

```js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(process.env.KOSOOR_WEBHOOK_SECRET); // "whsec_..."
const app = express();

app.post('/kosoor/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers); // checks the signature and the 5-minute window
  } catch {
    return res.status(400).end();
  }
  res.status(204).end();   // answer first
  queueForProcessing(event); // then work
});
```

### Python

```bash
pip install standardwebhooks
```

```python
import os
from flask import Flask, request
from standardwebhooks import Webhook

webhook = Webhook(os.environ["KOSOOR_WEBHOOK_SECRET"])  # "whsec_..."
app = Flask(__name__)

@app.post("/kosoor/webhooks")
def kosoor_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    queue_for_processing(event)
    return "", 204
```

### C# (ASP.NET Core)

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

app.MapPost("/kosoor/webhooks", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body, Encoding.UTF8);
    var body = await reader.ReadToEndAsync();
    var id = request.Headers["webhook-id"].ToString();
    var timestamp = request.Headers["webhook-timestamp"].ToString();
    var signatures = request.Headers["webhook-signature"].ToString().Split(' ');

    if (!long.TryParse(timestamp, out var seconds)
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300)
        return Results.BadRequest();

    var secret = Environment.GetEnvironmentVariable("KOSOOR_WEBHOOK_SECRET")!; // "whsec_..."
    var key = Convert.FromBase64String(secret["whsec_".Length..]);
    var expected = Encoding.UTF8.GetBytes(
        "v1," + Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"))));

    if (!signatures.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected)))
        return Results.BadRequest();

    QueueForProcessing(body);
    return Results.NoContent();
});
```

### Check it by hand

To see the calculation with nothing but a shell:

```bash
SECRET="whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w="
ID="019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74"
TS="1794043800"
BODY='{"id":"019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74","object":"event",...}'

KEY_HEX=$(echo -n "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf 'v1,%s\n' "$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary | base64)"
```

The output is what `webhook-signature` carries for that body.

## Answer fast, then work

- Answer any `2xx` within **15 seconds**. A slower answer counts as a failure and the event is sent again.
- Do the work after answering: put the event on a queue, answer `204`, process it from the queue.
- Redirects are not followed: a `3xx` counts as a failure. Register the final URL.

## Retries

An attempt fails when your endpoint answers anything but `2xx`, takes more than 15 seconds, or cannot be reached. Kosoor then tries again after:

| After attempt | Wait |
|---|---|
| 1 | 5 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
| 6 | 10 hours |
| 7 | 14 hours |
| 8 | 20 hours |
| 9 | 24 hours |

Each wait is lengthened by up to 10% at random, so a backlog does not arrive all at once. After the 10th attempt, about 3 days after the first, the event is marked failed. Answer `429` or `503` with a `Retry-After` header and Kosoor waits at least that long before the next attempt.

## Duplicates and order

- **The same event can arrive twice**: when your answer was lost on the way back, or when it was sent again from the dashboard. Keep the `webhook-id` of every event you processed and skip one you have already seen.
- **Events can arrive out of order.** Retries overlap with newer events. Decide what to do from the document's current `zatca.status` (read it with `GET` when in doubt), not from the order events arrive in.

## When Kosoor stops sending

| `disabledReason` | Why |
|---|---|
| `gone` | your endpoint answered `410 Gone`: Kosoor stops at once |
| `failing` | nothing was delivered for 5 days |
| `merchant` | the merchant switched it off in the dashboard |

A stopped endpoint shows `status: "disabled"` on `GET`, and what it had waiting is cancelled. The merchant can switch it back on from **Developers → Webhooks** in the dashboard, where every delivery is listed with its attempts, the last answer your endpoint gave, and a **Resend** button that sends it again under the same `webhook-id`.

![The Webhooks tab: endpoints, then every event sent with its attempts and last answer](https://docs.kosoor.com.sa/img/onboarding/webhooks.en.png.md)

## Rotating the secret

```bash
curl -X POST https://api.kosoor.com.sa/v1/webhook-endpoints/4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63/rotate-secret \
  -H "Authorization: Bearer ksr_live_..." \
  -H "Idempotency-Key: rotate-2026-11"
```

The reply carries the new `secret`. For the next **24 hours** Kosoor signs every request with both the old and the new secret, so `webhook-signature` carries two `v1,` values. Deploy the new secret within that window; a verifier holding either one keeps working.

## Try it

1. Create a test key and an endpoint pointing at a URL you can watch, such as a request-bin service.
2. `POST /v1/webhook-endpoints/{id}/test`: the reply's `id` is the `webhook-id` you receive.
3. Subscribe to `salesInvoice.zatcaCleared` and sell to the sandbox customer **Sandbox ZATCA Pending Co**: the invoice answers `pending`, ZATCA's test environment clears it shortly after, and the event arrives. See [Test mode](https://docs.kosoor.com.sa/test-mode.md).

Source: https://docs.kosoor.com.sa/webhooks
