Webhook Event Format
The HTTP request Spare sends to your callback URL, with decoded examples for each resource type.
When an event fires, Spare sends one POST request to each active subscription URL that matches the resource and product. The body is a compact JWT signed with ES256. Your server must return 202 Accepted. Any other status triggers a retry.
HTTP request
POST https://your-server.example.com/webhooks/spare
content-type: application/jose
x-spare-webhook-id: evt_01JZZQ8K5M2N3P4Q5R6S7T8U9V
x-spare-webhook-delivery-id: del_01JZZQ8K5M2N3P4Q5R6S7T8UAW
x-spare-webhook-attempt: 1
x-spare-webhook-version: v1.0
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ijg3MGQ2ZmM4In0.eyJpc3MiOiJodHRwczovL2FwaS50cnlzcGFyZS5hZSIsInN1YiI6IndlYmhvb2s6ZXZlbnQiLCJhdWQiOiJjbGlfMDFKWlpRMTIzNDU2Nzg5MCIsImp0aSI6ImV2dF8wMUpaWlE4SzVNMk4zUDRRNVI2UzdUOFU5ViIsImlhdCI6MTc1NzM0ODc1NCwiZXhwIjoxNzU3MzQ5MDU0LCJldmVudCI6e319.signature| Header | Description |
|---|---|
content-type | Always application/jose. The body is a compact JWT, not JSON. |
x-spare-webhook-id | Stable event ID (evt_...). Identical on every retry of the same event. Use this to deduplicate. |
x-spare-webhook-delivery-id | Delivery attempt ID (del_...). Changes on each retry. |
x-spare-webhook-attempt | Attempt counter, starting at 1. |
x-spare-webhook-version | Schema version, e.g. v1.0. |
JWT structure
Split the body on . to get three base64url segments: header, payload, signature.
Header:
{
"alg": "ES256",
"typ": "JWT",
"kid": "870d6fc8..."
}Payload:
{
"iss": "https://api.tryspare.ae",
"sub": "webhook:event",
"aud": "cli_01JZZQ1234567890",
"jti": "evt_01JZZQ8K5M2N3P4Q5R6S7T8U9V",
"iat": 1757348754,
"exp": 1757349054,
"event": { }
}| Claim | Description |
|---|---|
iss | Spare issuer URL. Match this against the known production or sandbox host. |
sub | Always webhook:event. |
aud | Your client ID. Verify it matches the credential that owns the subscription. |
jti | Same as x-spare-webhook-id. Use either for deduplication. |
iat / exp | Issued-at and expiry seconds (UTC). The JWT expires 5 minutes after issue. |
event | The outbound event envelope. See below. |
Verifying the signature
Fetch the signing key set from the JWKS endpoint:
GET https://api.sandbox.tryspare.ae/webhook/jwksThe response is a standard JWK Set ({ "keys": [...] }). Use the kid in the JWT header to pick the matching key, then verify the ES256 signature with any JWT library before trusting the payload.
import * as jose from 'jose';
const JWKS_URL = 'https://api.sandbox.tryspare.ae/webhook/jwks';
const jwks = jose.createRemoteJWKSet(new URL(JWKS_URL));
async function verifySpareWebhook(rawBody: string, clientId: string) {
const { payload } = await jose.jwtVerify(rawBody, jwks, {
algorithms: ['ES256'],
audience: clientId,
subject: 'webhook:event',
});
return payload.event; // the outbound envelope
}Event envelope fields
The event claim carries the outbound envelope:
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID (evt_...). Stable across retries. |
type | string | Public event type, e.g. payment_request.completed. |
version | string | Schema version, e.g. v1.0. |
occurredAt | string | ISO 8601 timestamp with +00:00 offset. |
product | string | Product that emitted the event, e.g. payments. |
tenant | string | Tenant, e.g. UAE. |
clientId | string | Your client ID. |
resource.type | string | Resource type: payment_request, consent, payment, mandate, mandate_transaction. |
resource.id | string | ID of the resource that changed. |
correlation | object | IDs linking related resources. Always includes paymentRequestId. |
data.status | string | Platform status at the time of the event. |
data.previousStatus | string | Previous platform status, when available. |
Sample envelopes by resource type
Event is a notification, not a source of truth
The event carries a status snapshot at delivery time. For authoritative state, fetch the resource directly after receiving the event: for example, GET /payment-requests/{paymentRequestId} after a payment_request.* event.