Partner API

Webhooks for partners.

Receive signed HTTP POSTs when partner events fire — wallet movements, account state changes, more as they ship. HMAC-SHA256 signatures with replay protection; exponential backoff retry; auto-disable on dead endpoints.

Setup

Webhook delivery configuration lives in the partner portal under Webhooks. Each partner can register multiple endpoint URLs (e.g. one for your ops Slack relay, one for your billing system) and filter each to a subset of event types.

  1. Click New webhook. Enter the destination URL and an optional description.
  2. Choose Send all events (recommended for first-time integrations) or an explicit subset. Pick whichever events your handler actually does something with — extra events are wasted requests.
  3. Submit. The response shows your signing secret exactly once — a 64-character hex string. Copy it immediately and store it where your verification code can read it.
  4. Implement signature verification (next section). Until your endpoint returns 2xx for at least one delivery, every retry counts against the auto-disable threshold.
Rotating the secret isn't a one-click operation today. Delete the webhook and create a new one; old deliveries stop, new deliveries get the new secret.

Verifying signatures

Every request carries an Esimple-Signature header with two comma-separated values:

text
Esimple-Signature: t=1715789340,v1=8a4b...c91e
  • t=<unix_seconds> — the timestamp at which we signed this delivery
  • v1=<hex_hmac>HMAC_SHA256(signing_secret, `$${t$}.$${raw_body$}`)

To verify a delivery:

  1. Read Esimple-Signature from the request headers
  2. Split on comma, parse out t and v1
  3. Re-compute HMAC_SHA256(secret, `$${t$}.$${raw_body$}`) on your side
  4. Compare to v1 using constant-time equality (not === — timing-safe)
  5. Reject if t is more than 5 minutes off your server clock (replay defence)

Node.js

javascript
import crypto from 'node:crypto'; import express from 'express'; const app = express(); const SECRET = process.env.SEAMLESS_WEBHOOK_SECRET; // from your dashboard const MAX_SKEW_MS = 5 * 60 * 1000; // IMPORTANT: read the raw body so the HMAC matches what we signed. // app.use(express.json()) re-serializes the JSON and bytes won't match. app.post( '/webhooks/seamless', express.raw({ type: 'application/json' }), (req, res) => { const header = req.headers['esimple-signature'] || ''; const parts = Object.fromEntries( header.split(',').map((p) => p.trim().split('=')) ); const ts = Number(parts.t); const v1 = parts.v1 || ''; if (!ts || !v1) return res.status(400).send('missing_signature'); if (Math.abs(Date.now() - ts * 1000) > MAX_SKEW_MS) { return res.status(400).send('stale_signature'); } const expected = crypto .createHmac('sha256', SECRET) .update(`${ts}.${req.body.toString('utf8')}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(v1); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(400).send('bad_signature'); } const event = JSON.parse(req.body.toString('utf8')); // Handle event.type / event.data — return 2xx to acknowledge res.status(200).send('ok'); } );

Python (Flask)

python
import hmac, hashlib, time from flask import Flask, request app = Flask(__name__) SECRET = os.environ['SEAMLESS_WEBHOOK_SECRET'] MAX_SKEW_SEC = 5 * 60 @app.post('/webhooks/seamless') def receive(): header = request.headers.get('Esimple-Signature', '') parts = dict(p.strip().split('=', 1) for p in header.split(',')) ts = int(parts.get('t', 0)) v1 = parts.get('v1', '') if not ts or not v1: return ('missing_signature', 400) if abs(time.time() - ts) > MAX_SKEW_SEC: return ('stale_signature', 400) raw = request.get_data() # bytes, not parsed expected = hmac.new( SECRET.encode('utf-8'), f'{ts}.{raw.decode("utf-8")}'.encode('utf-8'), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, v1): return ('bad_signature', 400) event = request.get_json(force=True) # Handle event['type'] / event['data'] return ('ok', 200)
Always read the raw body before JSON-parsing. Most frameworks' default JSON middleware re-serializes the request, and re-serialized bytes won't match the HMAC we signed.

Event catalog

The type field on the envelope tells you which event fired. Today's catalog:

TypeWhen it firesNotes
wallet.topup.succeededA wallet top-up landed — operator-added balance, Stripe checkout completion, or admin adjustment.data carries amountUsd + balanceUsd (post-credit) + reference.
wallet.topup.failedA top-up attempt errored before crediting the wallet.data carries amountUsd + balanceUsd (unchanged) + reason.
wallet.balance.lowA debit just crossed the partner's soft alert threshold (default $50).Throttled to once per 24h while balance stays low. data carries balanceUsd + thresholdUsd.
wallet.balance.negativeA debit just took the wallet below $0 (drawing on credit allowance).Throttled. data carries balanceUsd + creditThresholdUsd.
wallet.account.blockedA debit attempt was refused because balance + credit allowance was exhausted.data carries shortfallUsd. Order processing is paused until the partner tops up.
webhook.testOperator or partner hit the Test button in the dashboard.Match this type and short-circuit; useful as a heartbeat.

New event types are additive — your handler should branch on known types and ignore unknown ones. We bump types here when we add them, never break existing ones.

Payload envelope

Every delivery body is JSON with this shape:

json
{ "id": "evt_b3a8e0e0-2f4a-4b1f-91d3-7c2a9e1d22b7", "type": "wallet.balance.low", "created": "2026-05-15T12:34:56.789Z", "delivery_attempt": 1, "data": { "balanceUsd": 12.50, "thresholdUsd": 50.00, "prevBalanceUsd": 75.00, "creditThresholdUsd": 100.00, "partnerName": "Acme Travel" } }
FieldTypeNotes
idstringGlobally unique event id (prefix evt_, UUID). Use for idempotency on your side.
typestringThe event type — see catalog above.
createdstringISO-8601 timestamp of when this attempt was signed.
delivery_attemptintegerAttempt number, starting at 1. We retry on non-2xx — see Retry & failure.
dataobjectEvent-specific payload. Shape varies by type but field names are stable.
The id field is the safest idempotency key. We re-sign on every attempt (so the signature differs per delivery), but the event id stays the same — store it on first receipt and skip duplicates.

Retry & failure

A delivery is considered successful on any HTTP 2xx response within 10 seconds. Everything else (non-2xx, timeout, network error) is a failure and goes back on the queue.

Retry schedule, by attempt number:

AttemptDelay since previous failureCumulative elapsed
1Immediate0
230 seconds~30s
35 minutes~5m
41 hour~1h
56 hours~7h
6 (final)24 hours~31h

After attempt 6 fails, the delivery is marked dead and stops retrying. Your admin / partner UI surfaces dead deliveries in the delivery log — you can re-fire them manually once your endpoint is fixed.

Auto-disable: after 10 consecutive delivery failures across attempts, your webhook flips to disabled and new events stop enqueueing. Toggle it back to active in the dashboard to resume — the failure counter resets when you re-enable.

Best practice: return 2xx fast, work async

Acknowledge inside 10 seconds even if your downstream processing is slow. Push the event.id + data onto your own queue and return 200 immediately. Failed async processing is your problem to solve in your own infrastructure; we just need the acknowledgment so we don't retry.

Header reference

HeaderExampleNotes
Content-Typeapplication/jsonAlways.
User-AgentSeamless-Webhooks/1.0Lets you filter our traffic in logs.
Esimple-Signaturet=1715789340,v1=8a4b…See Verifying signatures.
Esimple-Event-Idevt_b3a8…Mirrors envelope.id. Cheap idempotency key without parsing the body.

Event filters

Each webhook can be filtered to a subset of event types. In the dashboard, uncheck Send all events and tick the specific types you want to receive. Filter changes apply immediately; in-flight deliveries that no longer match the filter are still attempted (the filter is evaluated at enqueue time, not delivery time).

When in doubt, leave the filter open. Your handler branches on event.type anyway — extra events are a small bandwidth cost, not a correctness issue.

Testing

The Test button in the webhook dashboard fires a synthetic webhook.test event synchronously and shows the result inline (HTTP code, response time, error if any). It bypasses the retry queue — you get one shot, you see the outcome immediately.

The synthetic event payload looks like:

json
{ "id": "evt_<uuid>", "type": "webhook.test", "created": "2026-05-15T...", "delivery_attempt": 1, "data": { "message": "This is a test event from Seamless. Match type === 'webhook.test' in your handler to ignore.", "triggered_at": "2026-05-15T..." } }

Your handler should match event.type === 'webhook.test' and short-circuit (return 200) without taking any real action. Treat it as a heartbeat.

Need an ad-hoc throwaway endpoint? webhook.site gives you a unique URL that inspects every incoming request — invaluable while you're wiring up signature verification for the first time.

Need help?

Webhook integrations questions: reply to your partner-onboarding email thread or write to partners@esimple.ai. If you haven't applied yet: start an application.

Webhook API — Seamless partners · esimple