Webhooks
A webhook is how you learn that a payment’s status changed. It is the authoritative signal — build on it, not on the create response and not on polling.
You implement the verifying half of our signature scheme, so this page states it precisely. Every ambiguity left here becomes an hour of someone’s afternoon wondering why their HMAC does not match ours.
The headers on every delivery
Section titled “The headers on every delivery”| Header | Example | What it is |
|---|---|---|
X-PaySigna-Signature |
v1=5d41402abc…,v1=aab3238922… |
One or more signatures. See below — there can be more than one. |
X-PaySigna-Timestamp |
1759500000 |
Unix seconds, base 10, no fraction. Part of the signed material. |
X-PaySigna-Event-Id |
evt_01JB3K2P4RT… |
The de-duplication key. Stable across retries of the same event. |
X-PaySigna-Event-Type |
transaction.succeeded |
What happened. |
X-PaySigna-Delivery-Id |
dlv_01JB3K2Q… |
This attempt’s delivery record. |
X-PaySigna-Sequence |
48213 |
Monotonic per account. A gap means a lost event. |
X-PaySigna-Attempt |
1 |
Which attempt this is. |
The signature
Section titled “The signature”The signed material — the canonical string — is:
<timestamp> "." <raw request body>Precisely:
<timestamp>is byte-for-byte the value of theX-PaySigna-Timestampheader. Do not re-derive it, reformat it, or use your own clock.- The separator is a single full stop.
<raw request body>is the exact bytes of the body as received, before any JSON parsing, re-serialisation or whitespace normalisation.
Each signature value is lowercase hex of HMAC-SHA256 over that string, keyed
with the endpoint’s signing secret, prefixed with v1=.
Why the timestamp is inside the signature and not just a header
Section titled “Why the timestamp is inside the signature and not just a header”A signature over the body alone is valid forever. Anyone who captures one request — a proxy log, a mis-shared debugging trace — can replay it at your endpoint for the rest of that endpoint’s life, and your verification will pass, because the signature genuinely is ours.
Binding the timestamp into the MAC means a replay is only accepted if you choose to accept an old timestamp. Reject anything more than five minutes old.
Why there can be more than one signature
Section titled “Why there can be more than one signature”Secret rotation. During the overlap window your endpoint has both a current and a previous secret, and we send a signature under each — current first — so that a deployment part-way through rotation verifies either way.
So your verification must:
- Split the header on
,rather than assuming a single value. - Accept the request if any
v1=element matches. - Compare in constant time (
hmac.compare_digest,crypto.timingSafeEqual,hmac.Equal). A plain==on a MAC leaks timing.
We sign primarily with the new secret and merely tolerate the old one, so a rotation takes effect immediately — which is what rotation is for. If you rotated because a secret leaked, traffic is not still being signed with the leaked key.
Verification
Section titled “Verification”import crypto from 'node:crypto';
// The raw body is required. A parsed-and-reserialised body will not verify.app.post('/paysigna/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const secret = process.env.PAYSIGNA_WEBHOOK_SECRET; const timestamp = req.get('X-PaySigna-Timestamp'); const header = req.get('X-PaySigna-Signature') ?? '';
// Reject stale deliveries: the signature alone cannot stop a replay. const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!timestamp || !Number.isFinite(age) || age > 300) { return res.status(400).send('stale'); }
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(req.body) // the raw Buffer .digest('hex'); const expectedBuf = Buffer.from(expected, 'hex');
// Any v1= element may match — during a rotation window there are two. const ok = header.split(',').some((part) => { const [scheme, hex] = part.trim().split('='); if (scheme !== 'v1' || !hex) return false; const given = Buffer.from(hex, 'hex'); return ( given.length === expectedBuf.length && crypto.timingSafeEqual(given, expectedBuf) ); });
if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// Enqueue and return. Do not process inline — see the timeout note below. enqueue(event); res.status(200).send('ok'); },);import hashlib, hmac, time
def verify(body: bytes, timestamp: str, signature_header: str, secret: str) -> bool: """body must be the raw request bytes, not a re-serialised dict.""" try: if abs(time.time() - int(timestamp)) > 300: return False except (TypeError, ValueError): return False
expected = hmac.new( secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256 ).hexdigest()
# Any v1= element may match: during a rotation window there are two. for part in signature_header.split(","): scheme, _, given = part.strip().partition("=") if scheme == "v1" and hmac.compare_digest(given, expected): return True return Falsefunc Verify(body []byte, timestamp, signatureHeader, secret string) bool { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil))
// Any v1= element may match: during a rotation window there are two. for _, part := range strings.Split(signatureHeader, ",") { scheme, given, found := strings.Cut(strings.TrimSpace(part), "=") if found && scheme == "v1" && hmac.Equal([]byte(given), []byte(expected)) { return true } } return false}function paysigna_verify(string $body, string $timestamp, string $header, string $secret): bool { if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
// Any v1= element may match: during a rotation window there are two. foreach (explode(',', $header) as $part) { [$scheme, $given] = array_pad(explode('=', trim($part), 2), 2, ''); if ($scheme === 'v1' && hash_equals($expected, $given)) { return true; } } return false;}The event body
Section titled “The event body”{ "id": "evt_01JB3K2P4RT7YV9X1Z3B5D7F9H", "object": "event", "type": "transaction.succeeded", "resource_type": "transaction", "resource_id": "txn_01JB3K2N8QZX4V7M9P0R5T6W8Y", "transaction_id": "txn_01JB3K2N8QZX4V7M9P0R5T6W8Y", "sequence": 48213, "created_at": "2026-10-05T14:22:41Z", "data": { "...": "the resource, in the shape the API returns it" }}data is the resource as the API would return it, so your handler can share code
with your read path. Any metadata you set on the original request comes back
unchanged here.
Event types
Section titled “Event types”The authoritative list is GET /v1/event-types. The ones most integrations
subscribe to:
| Type | When |
|---|---|
transaction.created |
A charge was accepted and recorded. |
transaction.succeeded |
Money collected. This is the one that means paid. |
transaction.failed |
A terminal failure. |
transaction.expired |
The customer did not complete in time. |
transaction.reversed |
Reversed after the fact. |
refund.succeeded / refund.failed |
A refund resolved. |
payout.initiated / .succeeded / .failed / .reversed |
A payout’s lifecycle. |
payout.held_for_review |
Held for a human decision; not a failure. |
settlement.created / .paid / .failed |
Settlement into your account. |
dispute.opened / .evidence_required / .won / .lost |
Chargebacks. evidence_required has a deadline. |
Subscribe explicitly with subscribed_events. An empty list means everything,
which costs you the deliveries and us the retries for events you ignore.
Writing a handler that survives
Section titled “Writing a handler that survives”Deduplicate on id. Delivery is at-least-once and always will be: an endpoint
that times out after having processed the request has to be retried, because the
alternative is dropping an event about money. The X-PaySigna-Event-Id is stable
across every retry of the same event, so record the ids you have processed and
ignore a repeat.
Enqueue, then return. We wait timeout_seconds (default 10) for your
response and count anything slower as a failed attempt. A handler that verifies,
writes the event to a queue and returns 200 cannot fail for a reason we would
retry; a handler that updates three tables and calls two APIs inline can.
Return 2xx for “received”, not for “succeeded”. A 2xx tells us to stop
retrying. If you return 500 because your downstream was briefly down, we retry
— which is usually what you want, but make it a deliberate choice rather than an
accident.
Watch the endpoint’s health. GET /v1/webhook-endpoints/{id} returns
health with consecutive_failures, last_failure_reason and — if we gave up —
auto_disabled_at. After repeated failures we stop delivering. Fix the endpoint,
then PATCH status back to active, which also clears the failure streak.
Track sequence. It is monotonic per account across every event type and
endpoint. Record the highest you have processed. A gap means a lost event, and it
is the only way you will find out.
Recovering missed events
Section titled “Recovering missed events”Do not build a polling loop for this. Use the event log.
# Everything since the last sequence you processed, OLDEST FIRST.curl "https://sandbox.paysigna.com/v1/events?after_sequence=48213" \ -H "Authorization: Bearer psg_test_…"after_sequence returns events oldest-first, which is the only order in which
applying missed events is correct. The default order is newest-first with a
cursor, which answers a different question; the two cannot be combined.
Reading the event log sends nothing, so you can backfill at your own pace without
your handler being hit by a burst of replays. The sequence walk is bounded by 90
days of history. data is returned byte-for-byte as we signed it, so a stored
X-PaySigna-Signature still verifies against it.
To re-send a specific delivery instead — useful while you are iterating on your
verification code — use POST /v1/webhook-deliveries/{id}/replay.
Rotating a signing secret
Section titled “Rotating a signing secret”curl -X POST https://sandbox.paysigna.com/v1/webhook-endpoints/{id}/rotate-secret \ -H "Authorization: Bearer psg_test_…"The response carries the new secret — once, like the original — and
previous_secret_expires_at. Until that moment, deliveries are signed with both
secrets and either verifies. After it, only the new one does.
Deploy the new secret before that expiry. If your verification follows the “any
v1= element may match” rule above, the rotation is invisible to your handler and
needs no downtime.

