Developers

Outbound Webhooks

We send signed HTTP POST notifications to your endpoint when key events happen (order fulfillment, refund, failure). All payloads are signed with Ed25519; you verify them against our public keys from /api/public/jwks.json — there is no shared secret on your side.

1. Configure your endpoint
Send us your HTTPS URL. No webhook secret to share — verification uses our public JWKS.

Requirements for your endpoint:

  • HTTPS only (HTTP rejected).
  • Respond with 2xx within 10 seconds.
  • Idempotent — we may retry on transient failures.
  • Capture and verify the raw request body before parsing JSON.
2. Verify the signature
Each request carries headers x-hub-event, x-hub-delivery, x-hub-signature-alg (always ed25519), x-hub-signature-kid, x-hub-signature-timestamp (unix seconds), and x-hub-signature (base64url Ed25519 signature over ${ts}.${raw_body}). Reject deliveries whose timestamp is more than 5 minutes off.

Node.js

import { createPublicKey, verify } from "node:crypto";

// 1. Cache the JWKS (e.g. for 5 minutes).
let jwksCache: { fetchedAt: number; keys: Map<string, ReturnType<typeof createPublicKey>> } | null = null;
async function getKey(kid: string) {
  if (!jwksCache || Date.now() - jwksCache.fetchedAt > 5 * 60_000) {
    const res = await fetch("https://sunrift-hub.com/api/public/jwks.json");
    const jwks = await res.json();
    const keys = new Map();
    for (const jwk of jwks.keys) keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
    jwksCache = { fetchedAt: Date.now(), keys };
  }
  return jwksCache.keys.get(kid);
}

// 2. Verify each incoming delivery against the raw body.
export async function verifyWebhook(req: { headers: Record<string,string>; rawBody: Buffer }) {
  const sig = req.headers["x-hub-signature"];
  const kid = req.headers["x-hub-signature-kid"];
  const ts  = req.headers["x-hub-signature-timestamp"];
  if (!sig || !kid || !ts) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 min window
  const key = await getKey(kid);
  if (!key) return false;
  const sigBuf = Buffer.from(sig.replace(/-/g, "+").replace(/_/g, "/"), "base64");
  const msg = Buffer.from(`${ts}.${req.rawBody.toString("utf8")}`);
  return verify(null, msg, key, sigBuf);
}

Python

import time, base64, httpx
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

_jwks_cache: dict | None = None
def get_key(kid: str):
    global _jwks_cache
    if not _jwks_cache or time.time() - _jwks_cache["t"] > 300:
        jwks = httpx.get("https://sunrift-hub.com/api/public/jwks.json").json()
        keys = {}
        for jwk in jwks["keys"]:
            x = base64.urlsafe_b64decode(jwk["x"] + "==")
            keys[jwk["kid"]] = Ed25519PublicKey.from_public_bytes(x)
        _jwks_cache = {"t": time.time(), "keys": keys}
    return _jwks_cache["keys"].get(kid)

def verify_webhook(raw_body: bytes, headers: dict) -> bool:
    sig, kid, ts = headers.get("x-hub-signature"), headers.get("x-hub-signature-kid"), headers.get("x-hub-signature-timestamp")
    if not (sig and kid and ts) or abs(time.time() - int(ts)) > 300:
        return False
    key = get_key(kid)
    if not key:
        return False
    sig_bytes = base64.urlsafe_b64decode(sig + "==")
    try:
        key.verify(sig_bytes, f"{ts}.{raw_body.decode()}".encode())
        return True
    except InvalidSignature:
        return False
3. Event payload
Every delivery wraps an event-specific payload with a stable envelope.
{
  "event_type": "order.fulfilled",
  "delivery_id": "8e2c…-uuid",
  "payload": {
    "order_id": "9d1f…-uuid",
    "external_order_id": "your-ref-123",
    "partner_order_id": "P-ABC123",
    "partner_slug": "partner_a",
    "sku_code": "partner_a.product_a.usd",
    "amount_retail": 50,
    "currency_retail": "EUR",
    "status": "fulfilled",
    "fulfilled_at": "2026-05-01T12:34:56.000Z"
  }
}
4. Retry policy

On non-2xx responses or network errors we retry up to 5 times with backoff: 1 min → 5 min → 30 min → 2 h → 6 h. After the final attempt the delivery is marked failed and surfaced to staff for manual replay.

The x-hub-delivery id stays constant across retries — use it for idempotency on your side.

5. Supported events
order.fulfilled
Order accepted by partner & ledger posted
order.failed
Adapter returned a terminal failure
order.refunded
Refund confirmed and ledger reversed