PaybytokenDocs
Checkout
Guide · Checkout

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.

Reliable fulfillment path
PaybytokenPayment eventCanonical state
Your endpointVerify raw bodyTimestamp + HMAC
Your databaseDeduplicate event IDAtomic write
Your businessFulfill orderThen return 2xx
Return a 2xx response only after durable receipt. Retries may arrive, so event IDs must be idempotent.

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.* or refund.*; or
  • an exact event such as payment_intent.succeeded.

The main checkout lifecycle events are:

ResourceEvents
Payment Intentcreated, updated, succeeded, payment_failed, canceled, amount_capturable_updated, partially_paid, amount_adjusted, overpaid, reconciliation_required, reconciliation_resolved
Checkout Sessioncreated, expired
Refundcreated, updated, succeeded, failed, canceled
Payoutcreated, succeeded, failed
Top-upcreated, updated
Transfercreated, succeeded, failed
Disputecreated, updated, resolved
Customer depositdetected, confirmed, credited, below_minimum, restricted, reversed
Customer withdrawalcreated, succeeded, failed, requires_attention
Webhook endpointtest
Payment Requestcreated, 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:

  1. insert event.id into a table with a unique constraint;
  2. lock or atomically transition the matching order;
  3. verify the Payment Intent, expected order, amount and currency;
  4. mark the order paid only if it was not already paid; and
  5. 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.

On this page

API Workbench

Full Explorer

Open in new tab