Webhooks
Get notified when payment and payout events occur. Pymstr sends HTTP POST requests to your endpoint with event details.
Setup#
- Go to Dashboard > Webhooks
- Enter your endpoint URL (must be HTTPS in production)
- Save your webhook secret — it's shown only once
Event Types#
| Event | Description |
|---|---|
payment.created | Payment link was created (dashboard-created links only — API-key creates do not emit it) |
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 |
payout.created | Payout was created (dashboard-created only — API-key creates do not emit it) |
payout.processing | Payer initiated the payout |
payout.completed | Payout confirmed on-chain |
payout.failed | Payout failed (transaction error) |
payout.expired | Payout expired before completion |
payout.cancelled | Payout was cancelled |
test | Test 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:
| Header | Description |
|---|---|
X-Pymstr-Timestamp | Unix timestamp (seconds) |
X-Pymstr-Signature | Format: t=<timestamp>,v1=<signature> |
Verification steps
- Extract
t(timestamp) andv1(signature) from the header - Reject if timestamp is older than 5 minutes (replay protection)
- Compute HMAC-SHA256 of
{timestamp}.{raw_body}using your webhook secret - Compare your computed signature with
v1using 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.
| Attempt | Delay | 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 |
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.