SpareSpare Docs
GuidesAPI Reference

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:

HeaderExamplePurpose
Content-Typeapplication/joseTells you the body is a compact JWS
x-spare-webhook-idevt_01H...Event id, matching JWT jti and event.id. Use as your idempotency key.
x-spare-webhook-delivery-iddel_01H...Identifies this subscription's delivery of that event. Stays the same across retries.
x-spare-webhook-attempt1Which attempt: 1 on first POST, then 2, 3, 4.
x-spare-webhook-versionv1.0Envelope 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/jwks

This 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

  1. Check that Content-Type is application/jose. Allow a trailing charset parameter.
  2. Read the raw body as a string. Parse the compact JWS and take kid from the protected header.
  3. Fetch GET /webhook/jwks and select the JWK whose kid matches. Use your cached copy when available; refetch on an unknown kid.
  4. Verify the signature. Restrict the accepted algorithm to ES256 only. Never let the token's own alg claim determine how it is verified.
  5. Check the JWT claims:
    • sub is webhook:event
    • aud is your client id
    • iss is the Spare webhook issuer for the environment
    • exp is in the future (a few seconds of clock tolerance is reasonable)
    • jti matches event.id and the x-spare-webhook-id header
  6. After all checks pass, read payload.event and 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 responseWhat Spare does
202 AcceptedSuccess. Spare ignores the body and does not retry.
Any other status, including 200, 201, 204Treated as a failure. Spare retries.
Timeout, TLS error, connection reset, DNS failureTreated 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.

  1. Accept the request and read the raw body.
  2. Verify the JWS. Reject unverified requests without processing.
  3. Look up x-spare-webhook-id (or event.id) in your idempotency store. If already handled, reply 202 and stop.
  4. Record the event id and return 202 Accepted immediately, well inside the 10-second delivery timeout.
  5. 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 response

Retries

If Spare does not receive 202 within the delivery timeout, it retries on a fixed schedule:

AttemptWhen it happens
1Immediately
21 minute after the previous failure
35 minutes after the previous failure
415 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

FieldTypeDescription
idstringEvent id (evt_...). Use as idempotency key.
typestringPublic event name from the catalog, such as payment.completed. Branch on this.
versionstringEnvelope schema version. v1.0 today.
occurredAtstringWhen the change happened on Spare's side, ISO 8601 with +00:00. Retried deliveries keep the original timestamp.
productstringProduct that emitted the event, such as payments.
tenantstringTenant, such as UAE.
clientIdstringYour client id, matching the JWT aud claim.
resourceobjectThe entity whose state changed.
resource.typestringOne of payment_request, consent, payment, mandate, mandate_transaction.
resource.idstringPlatform id of the entity. Use it with the REST API to fetch the full record.
correlationobjectRelated ids for joining events to other records. Empty values are omitted.
dataobjectSmall snapshot of what changed.

Correlation Fields

Every payments event includes paymentRequestId. Other fields appear only when relevant. Treat all except paymentRequestId as optional.

KeyWhen it appears
paymentRequestIdAlways, on every payments event
consentIdConsent-linked events
mandateIdMandate and mandate-transaction events
mandateTransactionIdMandate-transaction events
bankAccountIdAccount-linked events, where applicable
internalReferenceSpare's internal reference on the payment request
merchantReferenceYour own reference, the externalReferenceId you supplied when creating the payment request
traceIdEnd-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.

FieldDescription
statusPlatform status after the change. Always present.
previousStatusPlatform status before the change. Omitted on creation events and when no prior value is available.
Other keysEvent-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 localhost and 127.0.0.1 during 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 202 from the registered URL. Spare does not follow 3xx.
  • 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/jose body (not parsed as JSON)
  • JWS verified against GET /webhook/jwks, restricted to ES256, with aud checked against your client id
  • Receiver returns 202 Accepted with an empty body, in under one second where possible
  • Idempotency store keyed on x-spare-webhook-id / event.id
  • Handler switches on event.type and ignores unknown types and data keys
  • 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

On this page