PYMSTR
Docs

Webhooks

Get notified when payment and payout events occur. Pymstr sends HTTP POST requests to your endpoint with event details.

Setup#

  1. Go to Dashboard > Webhooks
  2. Enter your endpoint URL (must be HTTPS in production)
  3. Save your webhook secret — it's shown only once

Event Types#

EventDescription
payment.createdPayment link was created (dashboard-created links only — API-key creates do not emit it)
payment.processingCustomer initiated payment
payment.completedPayment confirmed on-chain
payment.failedPayment failed (transaction error)
payment.expiredPayment expired before completion
payment.cancelledPayment was cancelled
payout.createdPayout was created (dashboard-created only — API-key creates do not emit it)
payout.processingPayer initiated the payout
payout.completedPayout confirmed on-chain
payout.failedPayout failed (transaction error)
payout.expiredPayout expired before completion
payout.cancelledPayout was cancelled
testTest event (from dashboard)

Payload Format#

All webhook payloads follow this structure. The payload is intentionally minimal — fetch GET /v1/payments/:id (or GET /v1/payouts/:id) for full details.

JSON
{
  "event": "payment.completed",
  "timestamp": "2026-02-24T10:05:00.000Z",
  "data": {
    "paymentId": "01234567-89ab-cdef-0123-456789abcdef",
    "externalId": "order-123"
  }
}

The test event has no data field.

For payout.* events, data.payoutId replaces data.paymentId — fetch GET /v1/payouts/:id for full details.

Signature Verification#

Every webhook includes these headers:

HeaderDescription
X-Pymstr-TimestampUnix timestamp (seconds)
X-Pymstr-SignatureFormat: t=<timestamp>,v1=<signature>

Verification steps

  1. Extract t (timestamp) and v1 (signature) from the header
  2. Reject if timestamp is older than 5 minutes (replay protection)
  3. Compute HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret
  4. Compare your computed signature with v1 using constant-time comparison
JavaScript
import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(body, signatureHeader, secret, tolerance = 300) {
  // Parse signature header
  const parts = signatureHeader.split(',')
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2)
  const signature = parts.find(p => p.startsWith('v1='))?.slice(3)

  if (!timestamp || !signature) {
    throw new Error('Invalid signature format')
  }

  // Check timestamp freshness (5-minute tolerance)
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - parseInt(timestamp)) > tolerance) {
    throw new Error('Timestamp too old')
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${body}`
  const hmac = createHmac('sha256', secret)
  hmac.update(signedPayload)
  const expected = hmac.digest('hex')

  // Constant-time comparison
  const sigBuffer = Buffer.from(signature, 'hex')
  const expectedBuffer = Buffer.from(expected, 'hex')

  if (sigBuffer.length !== expectedBuffer.length) {
    return false
  }

  return timingSafeEqual(sigBuffer, expectedBuffer)
}

// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-pymstr-signature']
  const body = req.body.toString()

  try {
    if (!verifyWebhook(body, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature')
    }

    const payload = JSON.parse(body)
    // Process the event...

    res.status(200).send('OK')
  } catch (error) {
    res.status(400).send(error.message)
  }
})

Retry Schedule#

Failed deliveries are retried with exponential backoff. After 7 attempts (~34.5 hours), the delivery is marked as failed.

AttemptDelayCumulative
1Immediate0
21 minute1 minute
35 minutes6 minutes
430 minutes36 minutes
52 hours2h 36m
68 hours10h 36m
724 hours34h 36m

A delivery is considered successful on any HTTP 2xx response.

Test Webhook#

Send a test event from Dashboard > Webhooks. Click the "Send Test" button next to your configured endpoint.

Test event payload
{ "event": "test", "timestamp": "2026-02-24T10:00:00.000Z" }
Dashboard only
This is a session-authenticated action. It is not available via API key.

Best Practices#

Respond quickly
Return 200 immediately and process the event asynchronously. The webhook times out after 10 seconds.
Verify signatures
Always validate X-Pymstr-Signature and check the timestamp is within 5 minutes.
Be idempotent
Use paymentId (or payoutId) + event as a deduplication key. While Pymstr deduplicates on its end, network issues can cause duplicate deliveries.
Fetch current state
Events may arrive out of order. Always call GET /v1/payments/:id (or GET /v1/payouts/:id) to get the authoritative status before acting on an event.