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
| Event | What it means | Fulfill? |
|---|---|---|
checkout_session.completed | The hosted buyer flow completed; payment may still be processing | No |
checkout_session.expired | The session is no longer usable | No |
invoice.paid | The payment obligation reached authoritative paid state | Yes, after verification and deduplication |
payment_intent.*, charge.*, refund.* | Payment execution and refund transitions | No |
credit_note.created, credit_note.updated, credit_note.voided | Credit note lifecycle | No |
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
- Pharos persists the canonical checkout and payment state.
- Pharos sends the event JSON to the webhook URL configured for your account.
- Your server reads the raw request body and verifies
Pharos-Signaturewith the endpoint signing secret. - Your server parses the verified event and checks whether
idwas already processed. - Your server records the event identity and applies the account action atomically, or with an equivalent idempotent workflow.
- Your server returns a successful response only after accepting the delivery.
- 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, andrequest.idin 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.