Pharos Docs
Collections

Collections Webhooks

Verify checkout lifecycle events and fulfill purchases only after authoritative payment success.

Webhooks are the server-to-server completion path for Payment Links and Cart Checkout. Browser redirects improve the buyer experience, but they are not reliable proof of payment: the browser may close, a redirect may fail, or an asynchronous method may complete later.

Events to understand

EventWhat it meansFulfill?
checkout_session.completedThe hosted buyer flow completed; payment may still be processingNo
checkout_session.expiredThe session is no longer usableNo
invoice.paidThe payment obligation reached authoritative paid stateYes, after verification and deduplication
payment_intent.*, charge.*, refund.*Payment execution and refund transitionsNo
credit_note.created, credit_note.updated, credit_note.voidedCredit note lifecycleNo

invoice.paid is the authoritative fulfillment signal. checkout_session.completed can arrive while a payment is still processing.

For catalog-backed invoice lines, data.related.invoice.items includes productId and productPlanPriceId, so your fulfillment system can identify the purchased catalog item. Manual invoice lines omit those fields.

Delivery flow

  1. Pharos persists the canonical checkout and payment state.
  2. Pharos sends the event JSON to the webhook URL configured for your account.
  3. Your server reads the raw request body and verifies Pharos-Signature with the endpoint signing secret.
  4. Your server parses the verified event and checks whether id was already processed.
  5. Your server records the event identity and applies the account action atomically, or with an equivalent idempotent workflow.
  6. Your server returns a successful response only after accepting the delivery.
  7. Pharos may retry unsuccessful deliveries; duplicate handling must make every retry safe.

Verify the signature

Pharos sends Pharos-Signature: t=<unix>,v1=<hmac_sha256>. The digest is HMAC SHA-256 over <timestamp>.<raw_body>. Reject timestamps older than five minutes, then compare digests with a timing-safe comparison.

import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyPharosSignature(
  rawBody: Buffer,
  signatureHeader: string,
  signingSecret: string,
) {
  const fields = Object.fromEntries(signatureHeader.split(',').map(part => part.split('=', 2)))
  const timestamp = Number(fields.t)
  if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
  const received = fields.v1 ?? ''
  const expected = createHmac('sha256', signingSecret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex')

  const receivedBuffer = Buffer.from(received, 'hex')
  const expectedBuffer = Buffer.from(expected, 'hex')

  return receivedBuffer.length === expectedBuffer.length
    && timingSafeEqual(receivedBuffer, expectedBuffer)
}

Reject a delivery if the signature is missing, malformed, or invalid. Do not verify a re-serialized JSON object because whitespace and key ordering may differ from the signed body.

Deduplicate before fulfillment

Treat webhook delivery as at-least-once. Store the event id under a unique constraint. A repeated delivery must return success without creating a second side effect.

async function handleVerifiedEvent(event: PharosEvent) {
  if (await events.has(event.id)) return

  await database.transaction(async (tx) => {
    await tx.events.insert({
      eventId: event.id,
    })

    if (event.type === 'invoice.paid') {
      await tx.orders.markFulfilled({ invoiceId: event.data.object.id })
    }
  })
}

The example is illustrative: use the transaction, unique constraint, or durable workflow mechanism appropriate for your system.

Asynchronous payment methods

Transfers, tickets, cash, redirects, and other asynchronous methods can complete after checkout navigation finishes. A session may therefore be complete while payment is processing. Keep the order pending and wait for invoice.paid; do not infer success from elapsed time or the success page.

If a payment later fails, update the order from the corresponding verified payment event and expose a safe retry path. Reusing the same Checkout Session is valid only when its current lifecycle allows another attempt.

Operational checklist

  • Use HTTPS for the webhook target.
  • Keep the signing secret outside source control and rotate it through the supported configuration flow.
  • Capture id, type, and request.id in structured logs without logging secrets or sensitive payment data.
  • Respond quickly and move slow fulfillment work to an idempotent background workflow.
  • Alert on repeated delivery failures and reconcile successful payments that remain unfulfilled.
  • Test immediate success, redirect success, delayed success, failure, expiration, and duplicate delivery before production.

Delivery history and retries

Configured endpoints must use public HTTPS without URL credentials. Private, loopback, link-local and unsafe DNS destinations are rejected. Redirects are not followed. A delivery has a ten-second deadline; Pharos retains a bounded response for diagnosis.

Automatic delivery uses up to eight attempts over a maximum of 24 hours. Retries preserve the event ID and HMAC-signed event body. Deduplicate by the event ID; network failures mean external delivery cannot be promised exactly once.

Integration settings link to delivery history. A manual webhook retry targets that delivery only and does not resend the event to email or analytics consumers. Email acceptance and recipient-server delivery are separate states and neither indicates that a person read the message.

Commercial notifications require activation for the account. Reconciliation of older payments does not automatically send historical purchase notifications. Verify activation and callback configuration before relying on these events for live fulfillment.

Next steps