Webhooks
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, so the open-source standardwebhooks libraries verify its requests as they are.
Register an endpoint
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"]}'
{
"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:writeto manage endpoints (webhooks:readto list them), and read access to what the events are about:sales_invoices:readforsalesInvoice.*,credit_notes:readforcreditNote.*. - 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) |
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
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=
{
"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": []
}
}
}
datanames the document and carries itszatcablock, the same blockGETreturns. Read the whole document withGET /v1/sales-invoices/{id}orGET /v1/credit-notes/{id}when you need more.idis the event's id. It equals thewebhook-idheader and stays the same on every retry.createdAtis when the event was raised, in UTC;webhook-timestampis 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
npm install standardwebhooks
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
pip install standardwebhooks
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)
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:
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
2xxwithin 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
3xxcounts 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-idof 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 withGETwhen 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.

Rotating the secret
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
- Create a test key and an endpoint pointing at a URL you can watch, such as a request-bin service.
POST /v1/webhook-endpoints/{id}/test: the reply'sidis thewebhook-idyou receive.- Subscribe to
salesInvoice.zatcaClearedand sell to the sandbox customer Sandbox ZATCA Pending Co: the invoice answerspending, ZATCA's test environment clears it shortly after, and the event arrives. See Test mode.