Real-time payment events. Cryptographically signed.
Seven event types. HMAC-SHA256 signatures with a 5-minute timestamp tolerance. Automatic retries up to 34 hours. Same delivery system that powers production merchants today.
What you receive.
- payment.created
- Payment link was created
- payment.processing
- Customer initiated payment
- payment.completed
- Payment confirmed on-chain
- payment.failed
- Payment failed (transaction error)
- payment.expired
- Payment expired before completion
- payment.cancelled
- Payment was cancelled
- test
- Test event (from dashboard)
Same shape, every event.
Lookup the full payment via GET /v1/payments/{id} after verifying the signature. The webhook payload is intentionally small, just enough to identify the payment without trusting client-side data.
{
"event": "payment.completed",
"timestamp": "2026-05-19T07:03:42Z",
"data": {
"paymentId": "8f3a9c2d-1b6e-4d8a-9c2e-3f4b5d6e7a8c",
"externalId": "order-1234"
}
}Verify before you trust the payload.
Every event includes an X-Pymstr-Signature header with the format t=<unix_timestamp>,v1=<hmac_signature>. Verify it against your webhook secret using HMAC-SHA256 over {timestamp}.{raw_body}.
Reject events where the timestamp drifts by more than 5 minutes (300 seconds). This protects against replays. Always use a constant-time comparison (crypto.timingSafeEqual / hmac.compare_digest) when comparing the signature.
import crypto from 'node:crypto';
function verifyPymstrSignature(rawBody, header, secret, tolerance = 300) {
// header format: "t=<unix_timestamp>,v1=<hmac_signature>"
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2))
);
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1;
// 1. Reject stale timestamps (5-minute tolerance by default)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance) return false;
// 2. Recompute HMAC-SHA256 over "<timestamp>.<raw_body>"
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// 3. Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
);
}Built-in retries. Widening backoff.
Return 200 within 10 seconds and the event is delivered. Otherwise, PYMSTR retries up to 7 times with increasing delays. As long as your endpoint comes back within the 34h 36m window, the event lands.
| Attempt | Delay from previous | Cumulative |
|---|---|---|
| #1 | Immediate | 0 |
| #2 | 1 minute | 1 minute |
| #3 | 5 minutes | 6 minutes |
| #4 | 30 minutes | 36 minutes |
| #5 | 2 hours | 2h 36m |
| #6 | 8 hours | 10h 36m |
| #7 | 24 hours | 34h 36m |
Production checklist
HTTPS endpoint
PYMSTR delivers webhooks only to HTTPS endpoints.
Return 200 within 10 seconds
PYMSTR times out the webhook after 10 seconds. If your handler is slow, return 200 immediately and process the event asynchronously.
Store the secret securely
Same as the API key, store the secret securely in the backend. Do not expose.
Verify before processing
Run the HMAC verification first. Never trust data.paymentId or data.externalId until the signature passes. They're untrusted input otherwise.
Handle duplicates + ordering
Events may arrive out of order or be duplicated by retries. Dedupe on (data.paymentId, event) and treat your handlers idempotently. The same event can land twice.
Common questions.
PYMSTR retries 7 times with widening backoff: immediate, +1m, +5m, +30m, +2h, +8h, +24h. The last attempt fires 34h 36m after the first. As long as your endpoint returns a 2xx within that window, the event lands. After 7 failures we mark it permanently failed and surface it in the dashboard for manual re-delivery.