Skip to main content

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: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-endpointslist 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-secretissue a new secret (see Rotating the secret)
POST /v1/webhook-endpoints/{id}/testsend a webhookEndpoint.test event now

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

Events​

EventSent when
salesInvoice.zatcaClearedZATCA cleared a standard (B2B) invoice that had answered pending
salesInvoice.zatcaReportedZATCA accepted the report of a simplified (B2C) invoice
salesInvoice.zatcaRejectedZATCA rejected the invoice
creditNote.zatcaClearedthe same three, for a credit note
creditNote.zatcaReported
creditNote.zatcaRejected
webhookEndpoint.testyou 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": []
}
}
}
  • 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​

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 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 attemptWait
15 seconds
25 minutes
330 minutes
42 hours
55 hours
610 hours
714 hours
820 hours
924 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​

disabledReasonWhy
goneyour endpoint answered 410 Gone: Kosoor stops at once
failingnothing was delivered for 5 days
merchantthe 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

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​

  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.