Receive Events
Verify the signed delivery, return 202 Accepted, handle retries, and go live.
Spare delivers every event as a compact JWS (RFC 7515) signed with ES256. Your receiver must verify the signature before acting on the event. This page covers the full delivery cycle: the body format, signature verification, the response Spare expects, retry behaviour, the event envelope, callback URL requirements, and a go-live checklist.
The Delivery Body
Spare does not POST plain JSON. The body is a compact JWS with the media type application/jose: three base64url segments separated by dots.
<header>.<payload>.<signature>The JWS is a JWT (typ: JWT) signed with ES256 (ECDSA using P-256 and SHA-256). The event lives inside the JWT, so the first thing your receiver must do is read the raw body as a string and hand it to a JWT library. If your framework parses the body as JSON automatically, it will fail. Configure the route to accept a raw body for application/jose.
Delivery Headers
Spare sends these headers with every delivery:
| Header | Example | Purpose |
|---|---|---|
Content-Type | application/jose | Tells you the body is a compact JWS |
x-spare-webhook-id | evt_01H... | Event id, matching JWT jti and event.id. Use as your idempotency key. |
x-spare-webhook-delivery-id | del_01H... | Identifies this subscription's delivery of that event. Stays the same across retries. |
x-spare-webhook-attempt | 1 | Which attempt: 1 on first POST, then 2, 3, 4. |
x-spare-webhook-version | v1.0 | Envelope schema version, matching event.version. |
These headers are useful for logging and idempotency checks. They are not signed, so treat the verified JWT claims as the source of truth if a header and a claim disagree. Confirm x-spare-webhook-id matches event.id rather than trusting the header on its own.
There is no x-signature header on a delivery. The signature is the body.
Verifying a Delivery
Where the Public Keys Live
GET https://api.sandbox.tryspare.ae/webhook/jwksThis endpoint is anonymous. No Authorization header and no x-tenant are required.
The response is a plain RFC 7517 key set, { "keys": [ ... ] }, not wrapped in { "data": ... } like other Spare endpoints. Each entry is a public EC P-256 JWK: kty: EC, crv: P-256, alg: ES256, use: sig, plus kid, x, and y. The private component d is never published.
Spare sends Cache-Control: public, max-age=300, stale-while-revalidate=60. Cache the keys and refetch when you see a kid you do not recognise. Most JWT libraries have a remote-JWKS helper that handles caching automatically.
Use the webhook JWKS endpoint only
Spare publishes other JWKS documents for platform and provider authentication. Those keys will not verify webhook deliveries. Use GET /webhook/jwks specifically.
Verification Steps
- Check that
Content-Typeisapplication/jose. Allow a trailing charset parameter. - Read the raw body as a string. Parse the compact JWS and take
kidfrom the protected header. - Fetch
GET /webhook/jwksand select the JWK whosekidmatches. Use your cached copy when available; refetch on an unknownkid. - Verify the signature. Restrict the accepted algorithm to ES256 only. Never let the token's own
algclaim determine how it is verified. - Check the JWT claims:
subiswebhook:eventaudis your client idissis the Spare webhook issuer for the environmentexpis in the future (a few seconds of clock tolerance is reasonable)jtimatchesevent.idand thex-spare-webhook-idheader
- After all checks pass, read
payload.eventand act on the event data.
Verify before you reply. A forged or corrupted body must not receive a 202.
The 202 Response
Spare treats a delivery as successful only when your endpoint returns HTTP 202 Accepted.
| Your response | What Spare does |
|---|---|
202 Accepted | Success. Spare ignores the body and does not retry. |
Any other status, including 200, 201, 204 | Treated as a failure. Spare retries. |
| Timeout, TLS error, connection reset, DNS failure | Treated as a failure. Spare retries. |
A redirect (3xx) | Treated as a failure. Spare does not follow redirects. |
If your handler does its work and returns 200 OK, your logs will look healthy while Spare keeps retrying and you keep reprocessing the same event. This is the most common integration mistake.
Recommended Handler Pattern
- Accept the request and read the raw body.
- Verify the JWS. Reject unverified requests without processing.
- Look up
x-spare-webhook-id(orevent.id) in your idempotency store. If already handled, reply202and stop. - Record the event id and return
202 Acceptedimmediately, well inside the 10-second delivery timeout. - Do the real work on a queue or background worker: update orders, send notifications, call downstream systems.
Keep everything slow out of the request path. Aim for a response time well under one second to avoid retries from timing.
import express from "express";
import * as jose from "jose";
app.post(
"/webhooks/spare",
express.raw({ type: "application/jose" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
// 1. Verify the JWS
let payload: jose.JWTPayload;
try {
const JWKS = jose.createRemoteJWKSet(
new URL("https://api.sandbox.tryspare.ae/webhook/jwks"),
);
const { payload: verified } = await jose.jwtVerify(rawBody, JWKS, {
algorithms: ["ES256"],
audience: process.env.SPARE_CLIENT_ID,
subject: "webhook:event",
});
payload = verified;
} catch {
return res.status(401).send();
}
const eventId = req.headers["x-spare-webhook-id"] as string;
// 2. Idempotency check
if (await hasProcessed(eventId)) {
return res.status(202).send();
}
await markProcessing(eventId);
// 3. Acknowledge immediately
res.status(202).send();
// 4. Process in background
const event = (payload as { event: SpareEvent }).event;
await queue.add("spare-webhook", event);
},
);
async function processEvent(event: SpareEvent) {
switch (event.type) {
case "payment.completed":
await fulfillOrder(event.correlation.merchantReference);
break;
case "payment.failed":
await cancelOrder(event.correlation.merchantReference);
break;
default:
// Unknown event types are a no-op
break;
}
}from flask import Flask, request, abort
import jwt # PyJWT
import requests as http_requests
app = Flask(__name__)
def get_spare_public_keys():
res = http_requests.get(
"https://api.sandbox.tryspare.ae/webhook/jwks"
)
return jwt.PyJWKClient(
"https://api.sandbox.tryspare.ae/webhook/jwks"
)
jwks_client = get_spare_public_keys()
@app.route("/webhooks/spare", methods=["POST"])
def receive_webhook():
raw_body = request.get_data(as_text=True)
# 1. Verify the JWS
try:
signing_key = jwks_client.get_signing_key_from_jwt(raw_body)
payload = jwt.decode(
raw_body,
signing_key.key,
algorithms=["ES256"],
audience=os.environ["SPARE_CLIENT_ID"],
options={"verify_sub": True},
)
except Exception:
abort(401)
event_id = request.headers.get("x-spare-webhook-id")
# 2. Idempotency check
if has_processed(event_id):
return "", 202
mark_processing(event_id)
# 3. Acknowledge immediately
response = ("", 202)
# 4. Process in background (e.g. task queue)
enqueue_event(payload["event"])
return responseRetries
If Spare does not receive 202 within the delivery timeout, it retries on a fixed schedule:
| Attempt | When it happens |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the previous failure |
| 3 | 5 minutes after the previous failure |
| 4 | 15 minutes after the previous failure |
Four attempts total, spread over roughly 21 minutes. Every retry carries the same x-spare-webhook-id and x-spare-webhook-delivery-id. Only x-spare-webhook-attempt increments. After the fourth failure, Spare stops. The event will not be delivered to that subscription again. If your receiver has an extended outage, reconcile the affected window through the REST API.
The Event Envelope
After verifying the JWT, read the event claim. The event data is not at the root of the JWT; it is inside the event claim.
{
"id": "evt_01JZ9VTEST0000000000000000",
"type": "payment.completed",
"version": "v1.0",
"occurredAt": "2026-07-07T10:15:30.123+00:00",
"product": "payments",
"tenant": "UAE",
"clientId": "your-client-id",
"resource": {
"type": "payment",
"id": "pay_123"
},
"correlation": {
"paymentRequestId": "pr_123",
"consentId": "con_123",
"internalReference": "INT-1",
"merchantReference": "order-123"
},
"data": {
"status": "AcceptedCreditSettlementCompleted",
"previousStatus": "AcceptedWithoutPosting"
}
}Envelope Fields
| Field | Type | Description |
|---|---|---|
id | string | Event id (evt_...). Use as idempotency key. |
type | string | Public event name from the catalog, such as payment.completed. Branch on this. |
version | string | Envelope schema version. v1.0 today. |
occurredAt | string | When the change happened on Spare's side, ISO 8601 with +00:00. Retried deliveries keep the original timestamp. |
product | string | Product that emitted the event, such as payments. |
tenant | string | Tenant, such as UAE. |
clientId | string | Your client id, matching the JWT aud claim. |
resource | object | The entity whose state changed. |
resource.type | string | One of payment_request, consent, payment, mandate, mandate_transaction. |
resource.id | string | Platform id of the entity. Use it with the REST API to fetch the full record. |
correlation | object | Related ids for joining events to other records. Empty values are omitted. |
data | object | Small snapshot of what changed. |
Correlation Fields
Every payments event includes paymentRequestId. Other fields appear only when relevant. Treat all except paymentRequestId as optional.
| Key | When it appears |
|---|---|
paymentRequestId | Always, on every payments event |
consentId | Consent-linked events |
mandateId | Mandate and mandate-transaction events |
mandateTransactionId | Mandate-transaction events |
bankAccountId | Account-linked events, where applicable |
internalReference | Spare's internal reference on the payment request |
merchantReference | Your own reference, the externalReferenceId you supplied when creating the payment request |
traceId | End-to-end trace id. Quote this when opening a support ticket about a specific event. |
merchantReference is often the most useful field in the envelope. It is the value you set when creating the payment request, for example your order number or invoice id. Joining events to your database on this field does not require a lookup table of Spare ids. If you are not setting externalReferenceId on payment request creates, start doing so.
The data Object
data is a small snapshot of the state transition, not a full copy of the resource.
| Field | Description |
|---|---|
status | Platform status after the change. Always present. |
previousStatus | Platform status before the change. Omitted on creation events and when no prior value is available. |
| Other keys | Event-specific. For example, type and providerId on some consent events, or externalPaymentId on bank-originated payment updates. |
Ignore keys you do not recognise. Spare may add fields to data over time. Unknown fields must not break your receiver. Do not expect amounts, IBANs, or payer personal data in data. Fetch the resource via resource.id when you need the full record.
Callback URL Requirements
- Use HTTPS in both sandbox and production. Plain HTTP is only accepted for
localhostand127.0.0.1during local development. - The URL must be publicly reachable. Spare rejects loopback, private, and link-local ranges (
10/8,127/8,169.254/16,172.16/12,192.168/16, IPv6 unique-local and link-local, and cloud metadata endpoints) at registration and re-checks at delivery time. A public hostname that later resolves to a private address stops receiving events. - Present a valid, publicly trusted TLS certificate. Self-signed certificates and expired chains cause delivery failures.
- Do not redirect. Answer with
202from the registered URL. Spare does not follow3xx. - Keep the URL stable. Changing where it resolves is fine as long as the new target is still public and returns
202.
Go-Live Checklist
- Authenticated against Spare and read
GET /webhooks/catalog - Receiver deployed on a public HTTPS URL with a valid certificate
- Receiver accepts a raw
application/josebody (not parsed as JSON) - JWS verified against
GET /webhook/jwks, restricted to ES256, withaudchecked against your client id - Receiver returns
202 Acceptedwith an empty body, in under one second where possible - Idempotency store keyed on
x-spare-webhook-id/event.id - Handler switches on
event.typeand ignores unknown types anddatakeys - Orders joined back to your system via
correlation.merchantReference - Subscription created with
x-signature, within the two-Active-per-product limit - A reconciliation path in place for events missed during an outage