Skip to content
Developers

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.

Events

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)
Payload

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 verification

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.

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 schedule

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

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.

Build with the stablecoin rail.