Webhooks

Receive invoice and certificate events at your HTTPS endpoint. Every delivery is persisted, retried automatically on failure, and visible in the delivery log.

Overview

  • Configure a single webhook URL per tenant (HTTPS only).
  • We send a POST with a JSON body for each event.
  • Every request includes X-ZATCA-Event (event type) and X-ZATCA-Delivery-ID (unique delivery id for idempotency).
  • Optionally set a signing secret; we then send X-ZATCA-Signature: sha256=<hmac_hex> (HMAC-SHA256 of the raw body).
  • Retries: Failed deliveries (non-2xx or timeout) are retried automatically up to 5 times with exponential backoff (1m, 5m, 15m, 1h). After that, events are marked permanently failed and can be retried manually via the API or dashboard.

Event types

EventWhen
invoice.reportedSimplified invoice (or credit/debit note) reported to ZATCA.
invoice.clearedStandard invoice (or credit/debit note) cleared by ZATCA.
webhook.testTriggered by "Send test event" (dashboard or API).
cert.expiringEGS unit certificate expiring within 30/14/7/1 days or expired.

Payloads always include event plus event-specific fields (e.g. invoice_id, uuid, status, document_type, invoice_type).

Verifying the signature

If you set a webhook secret, verify the X-ZATCA-Signature header using the raw request body (UTF-8) and your secret. Use constant-time comparison to avoid timing attacks.

// Node.js example
const crypto = require('crypto');
function verifySignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
GET/v1/webhooks/config

Get webhook configuration

Returns the current webhook URL and whether a signing secret is set. Secret value is never returned.

Requires API Key (Bearer)

Code examples

const res = await fetch('https://api.esnadapi.com/v1/webhooks/config', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const config = await res.json();

Success response (200)

{
  "webhook_url": "https://api.esnadapi.com/webhooks/zatca",
  "webhook_secret_set": true
}
PATCH/v1/webhooks/config

Update webhook URL and/or secret

Set or update the webhook URL (HTTPS only) and optional secret for X-ZATCA-Signature. Pass null to clear.

Requires API Key (Bearer)

Request body

WebhookConfigDto
FieldTypeRequiredDescription
webhook_urlstring | nullNoHTTPS URL; max 500 chars
webhook_secretstring | nullNoSecret for signing; max 64 chars

Code examples

await fetch('https://api.esnadapi.com/v1/webhooks/config', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    webhook_url: 'https://api.esnadapi.com/webhooks/zatca',
    webhook_secret: 'your-signing-secret',
  }),
});

Success response (200)

{
  "ok": true
}
POST/v1/webhooks/test

Trigger test webhook delivery

Sends a test payload to your configured webhook URL (synchronous). Returns an error if webhook_url is not set. The delivery appears in the events log.

Requires API Key (Bearer)

Code examples

const res = await fetch('https://api.esnadapi.com/v1/webhooks/test', {
  method: 'POST',
  headers: { Authorization: `Bearer ${apiKey}` },
});
const result = await res.json();

Success response (200)

{
  "sent": true,
  "message": "Test webhook fired",
  "delivery_id": "delivery-uuid"
}

Error responses

  • 200
    {
      "error": {
        "code": "WEBHOOK_NOT_CONFIGURED",
        "message": "Set webhook_url on your tenant to receive test events",
        "retryable": false
      }
    }
GET/v1/webhooks/events

List webhook delivery events

Returns recent webhook deliveries with status, attempt count, and full attempt history. Use query params to filter by status or event type. Cursor-based pagination.

Requires API Key (Bearer)

Query parameters

NameTypeRequiredDescription
limitnumberNoDefault: 50
cursorstringNoPagination cursor (ISO date of last item)
statusstringNoFilter: delivered | pending | failed | permanently_failed
event_typestringNoFilter: e.g. invoice.reported, invoice.cleared

Code examples

const res = await fetch(`${BASE}/v1/webhooks/events?limit=50&status=failed`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { data } = await res.json();

Success response (200)

{
  "data": [
    {
      "id": "delivery-uuid",
      "event_type": "invoice.reported",
      "invoice_id": "inv-uuid",
      "status": "delivered",
      "attempts": 1,
      "response_code": 200,
      "created_at": "2025-01-15T10:05:00.000Z",
      "last_attempt_at": "2025-01-15T10:05:01.000Z",
      "next_retry_at": null,
      "attempt_history": [
        {
          "attempt_number": 1,
          "attempted_at": "2025-01-15T10:05:01.000Z",
          "response_code": 200,
          "success": true,
          "error_message": null
        }
      ]
    }
  ],
  "next_cursor": null
}
POST/v1/webhooks/events/:id/retry

Retry a failed webhook delivery

Schedules one more delivery attempt for a failed or permanently_failed event. The attempt runs shortly after the request. Returns ok: true if the subsequent attempt succeeded.

Requires API Key (Bearer)

Code examples

await fetch(`${BASE}/v1/webhooks/events/${deliveryId}/retry`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${apiKey}` },
});

Success response (200)

{
  "ok": true
}