Skip to content

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.

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 signed material — the canonical string — is:

<timestamp> "." <raw request body>

Precisely:

  • <timestamp> is byte-for-byte the value of the X-PaySigna-Timestamp header. 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.

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:

  1. Split the header on , rather than assuming a single value.
  2. Accept the request if any v1= element matches.
  3. 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.

Node.js (Express)
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');
},
);
Python
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 False
Go
func 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
}
PHP
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;
}
{
"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.

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.

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.

Do not build a polling loop for this. Use the event log.

Terminal window
# 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.

Terminal window
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.