Home/Developers/Webhooks
DevelopersWebhooks

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.

//Events7 types01/06

What you receive.

payment.createdPayment link was created
payment.processingCustomer initiated payment
payment.completedPayment confirmed on-chain
payment.failedPayment failed (transaction error)
payment.expiredPayment expired before completion
payment.cancelledPayment was cancelled
testTest event (from dashboard)
//Payloadapplication/json02/06

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.

Webhook payloadapplication/json
{
  "event": "payment.completed",
  "timestamp": "2026-05-19T07:03:42Z",
  "data": {
    "paymentId": "8f3a9c2d-1b6e-4d8a-9c2e-3f4b5d6e7a8c",
    "externalId": "order-1234"
  }
}
//Signature verificationHMAC-SHA25603/06

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) — protects against replays. Always use a constant-time comparison (crypto.timingSafeEqual / hmac.compare_digest) when comparing the signature.

Signature verificationserver-side
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'),
  );
}
//Retry schedule7 attempts · 34h 36m max04/06

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.

AttemptDelay from previousCumulative
#1Immediate0
#21 minute1 minute
#35 minutes6 minutes
#430 minutes36 minutes
#52 hours2h 36m
#68 hours10h 36m
#724 hours34h 36m
//Production checklist5 items05/06
  • 01

    HTTPS endpoint

    PYMSTR delivers webhooks only to HTTPS endpoints.

  • 02

    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.

  • 03

    Store the secret securely

    Same as the API key, store the secret securely in the backend. Do not expose.

  • 04

    Verify before processing

    Run the HMAC verification first. Never trust data.paymentId or data.externalId until the signature passes — they're untrusted input otherwise.

  • 05

    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.

//Webhooks questions4 answers06/06
PYMSTR retries 7 times with widening backoff: immediate, +1m, +5m, +30m, +2h, +8h, +24h — 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.
Manage webhook endpoints and secrets in your dashboard under Webhooks. See docs.pymstr.com for the live rotation flow. General guidance: when rotating, allow your handler to accept either the old or new secret for the duration of the retry window (up to 34h 36m) so events signed under the old secret still verify until they've fully drained.
Yes. Dashboard → Webhooks → Send test event delivers a fully signed event of type "test". It traverses the same signing + retry pipeline as production events — use it to validate your verification logic before a real payment lands.
Each event includes a unique paymentId + event type pair. After verifying the signature, dedupe on (data.paymentId, event) — store processed pairs in your application database with a uniqueness constraint. Retries with the same payload are expected behavior; idempotent handling is on the merchant side.

Build with the stablecoin rail.