Fulfill orders with webhooks
Verify Paybytoken signatures and fulfill stablecoin orders exactly once.
A hosted return, embedded complete event, custom UI confirmation or customer screenshot is not
proof of payment.
Fulfillment must happen on your server after a verified terminal payment event.
1. Create an endpoint
Create an endpoint in the Merchant Portal or with the Node SDK:
const endpoint = await paybytoken.webhookEndpoints.create({
url: 'https://shop.example.com/webhooks/paybytoken',
enabled_events: ['payment_intent.succeeded'],
})Store the whsec_... value from the creation response in your secret manager. The secret is not
returned by normal endpoint retrieval.
Use a dedicated HTTPS route in live mode. Paybytoken rejects unsafe destinations and does not follow webhook redirects.
2. Preserve the raw request body
Paybytoken serializes the event once and signs:
HMAC-SHA256(webhook_secret, timestamp + "." + exact_raw_body)The X-Webhook-Signature header has this shape:
t=1783728000,v1=4e8f...During a signing-secret rotation it contains two v1 values. Accept a match against either the
current or previous secret until the overlap expires; do not require both signatures to match.
Read the body as text or a buffer before JSON parsing. Re-serializing parsed JSON changes the bytes and invalidates the signature.
import Paybytoken, {
type PaymentIntentSucceededWebhookData,
} from '@paybytoken/node'
const paybytoken = new Paybytoken(process.env.PAYBYTOKEN_SECRET_KEY!)
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get('x-webhook-signature') ?? ''
let event
try {
event = paybytoken.webhookEvents.constructEvent(
rawBody,
signature,
process.env.PAYBYTOKEN_WEBHOOK_SECRET!,
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
if (event.type === 'payment_intent.succeeded') {
const payment = event.data as PaymentIntentSucceededWebhookData
await acceptPaymentEventOnce({
eventId: event.id,
checkoutSessionId: payment.checkout_session_id,
paymentIntentId: payment.id,
amount: payment.amount,
currency: payment.currency,
})
}
return new Response('ok', { status: 200 })
}The SDK rejects stale timestamps by default after five minutes. You can pass an array of secrets during deliberate secret rotation:
paybytoken.webhookEvents.constructEvent(rawBody, signature, [
process.env.PAYBYTOKEN_WEBHOOK_SECRET!,
process.env.PAYBYTOKEN_PREVIOUS_WEBHOOK_SECRET!,
])Rotate and verify the endpoint before removing the previous secret:
const rotation = await paybytoken.webhookEndpoints.rotateSecret(endpoint.id)
// Store rotation.key as the current secret. Keep the previous value until:
console.log(rotation.previous_secret_expires_at)
const test = await paybytoken.webhookEndpoints.sendTestEvent(endpoint.id)
console.log(test.delivery_id)The test payload uses webhook_endpoint.test. It proves delivery and signature verification only;
never use it to fulfill an order.
3. Understand the event
A successful checkout payment is delivered as:
{
"id": "evt_t24d49WDnBzTTwcA",
"object": "event",
"type": "payment_intent.succeeded",
"api_version": "v1",
"account_id": "acct_merchant",
"livemode": false,
"created_at": 1785298452,
"data": {
"object": "payment_intent",
"id": "pay_t24c9qLoGieNDhvHVHWo",
"amount": "50.00",
"currency": "USDC",
"chain": "base",
"status": "succeeded",
"tx_hash": "0xabc...",
"checkout_session_id": "chk_test_RzULZl7bkylpMyo7bkHxinc2"
}
}Map checkout_session_id to the order when you create the session. Server-authored Checkout Session
metadata can also be retrieved with your secret key; it is not copied into this webhook payload.
Event names use dot notation. Colon-delimited names such as
payment_intent:succeeded are not valid subscription selectors.
Event selectors
An endpoint can subscribe to:
- every event with
*; - a resource group such as
payment_intent.*orrefund.*; or - an exact event such as
payment_intent.succeeded.
The main checkout lifecycle events are:
| Resource | Events |
|---|---|
| Payment Intent | created, updated, succeeded, payment_failed, canceled, amount_capturable_updated, partially_paid, amount_adjusted, overpaid, reconciliation_required, reconciliation_resolved |
| Checkout Session | created, expired |
| Refund | created, updated, succeeded, failed, canceled |
| Payout | created, succeeded, failed |
| Top-up | created, updated |
| Transfer | created, succeeded, failed |
| Dispute | created, updated, resolved |
| Customer deposit | detected, confirmed, credited, below_minimum, restricted, reversed |
| Customer withdrawal | created, succeeded, failed, requires_attention |
| Webhook endpoint | test |
| Payment Request | created, updated, finalized, sent, delivery_failed, payment_processing, payment_applied, payment_failed, payment_unapplied, paid, canceled, expired |
4. Fulfill exactly once
Webhook delivery is asynchronous and can be duplicated. In one database transaction:
- insert
event.idinto a table with a unique constraint; - lock or atomically transition the matching order;
- verify the Payment Intent, expected order, amount and currency;
- mark the order paid only if it was not already paid; and
- enqueue business fulfillment.
Return 2xx only after the event is durably accepted. Slow product fulfillment should run in your
own queue after the webhook response.
Delivery and retries
Paybytoken considers any 2xx response successful. Network failures, timeouts, 429 and 5xx
responses are retried with backoff. Other 4xx responses are treated as permanent failures.
Because a response can be lost after your handler commits, even a successful delivery can arrive
again. Concurrent delivery and retries can also arrive out of order. Idempotency is always required:
deduplicate by the stable event.id, and use created_at as event time rather than an ordering
guarantee. When a state transition matters, retrieve the current Paybytoken resource or accept only
valid forward transitions in your own database transaction.
You can inspect delivery status and request a retry from the Merchant Portal or through
paybytoken.webhookEvents.
const deliveries = await paybytoken.webhookEvents.list({
status: 'failed',
endpoint_id: endpoint.id,
})
const delivery = await paybytoken.webhookEvents.get(deliveries.data[0].id)
await paybytoken.webhookEvents.retry({ id: delivery.id })retry is valid only for a failed or retrying delivery and is rate-limited. It requeues the same
delivery record; it does not create a new business Event and must not cause duplicate fulfillment.
The Portal also shows 24-hour success rate, pending or failed deliveries and average delivery
latency. The same aggregate is available from
paybytoken.webhookEndpoints.metrics(endpoint.id, 24).
Browser completion
Use hosted returns, embedded complete or return events, and custom checkout state to show an
immediate pending screen. Do not ship an order, grant access or credit a balance from those browser
signals. If delivery is delayed, your server can retrieve the Checkout Session or Payment Intent
with its secret key.
Did this page answer your question?
Your feedback helps us improve the integration path.