Webhooks
Webhooks are HMAC-SHA256-signed event payloads delivered to your endpoint when something happens to a payment. They retry on failure and are the authoritative confirmation that a charge captured.
Events
Payment events
| Event | When |
|---|---|
payment.completed | Sale captured successfully (terminal state for a Sale flow) |
payment.captured | Authorized payment moved to captured (separate Auth → Capture flow) |
payment.capture_failed | Capture attempt on an authorized payment failed |
payment.failed | Payment declined or failed |
payment.refunded | Refund processed (full or partial) |
payment.refund_failed | Refund attempt declined or errored — original capture stays settled |
payment.voided | Authorized payment voided before capture |
payment.void_failed | Void attempt on an authorized payment failed |
Subscription events
| Event | When |
|---|---|
subscription.created | A new subscription was created (in trial or active) |
subscription.active | A subscription moved into the active state (e.g. after trial end) |
subscription.trialing | Trial started — no charge yet |
subscription.charged | Successful renewal cycle |
subscription.charge_failed | Renewal cycle declined — dunning starts on the merchant's schedule |
subscription.cancelled | Subscription cancelled (by merchant, customer, or after dunning) |
subscription.updated | Payment method or schedule changed |
Checkout-session events
| Event | When |
|---|---|
checkout.session.expired | Open session past its expires_at was swept (cron, every 5 min) |
checkout.cancelled | Customer hit Cancel on the hosted checkout page |
Payload
{
"event_id": "evt_abc123",
"event_type": "payment.completed",
"payload_redacted": {
"transaction_id": "txn_789xyz",
"receipt_number": "RCT-000042",
"amount": 2500,
"currency": "USD",
"status": "captured",
"subtotal": 2300,
"tax_amount": 200,
"tax_name": "VAT",
"payment_method_type": "card",
"card_brand": "Visa",
"card_last4": "0071",
"token_id": "tok_abc123",
"description": "Order #1042",
"line_items": [
{ "name": "T-shirt", "quantity": 1, "unit_amount": 2300 }
],
"customer": { "name": "Jane Doe", "email": "[email protected]" },
"metadata": { "order_id": "1042", "source": "woocommerce" }
}
}payload_redacted is the safe-to-log payment data — no full PAN, no CVV, only last4.
Field availability by event type
| Field | payment.* | payment.refunded | subscription.* | checkout.* |
|---|---|---|---|---|
transaction_id | ✅ | ✅ | ✅ | — |
receipt_number | ✅ | ✅ | ✅ | — |
amount, currency | ✅ | ✅ (refund amount) | ✅ | ✅ |
subtotal, tax_amount, tax_name | ✅ | ✅ | ✅ | ✅ |
payment_method_type | ✅ | ✅ | ✅ | — |
card_brand, card_last4 | when card | when card | when card | — |
token_id | when tokenized | when on-token | always | — |
line_items, description | ✅ | ✅ | ✅ | ✅ |
customer | ✅ | ✅ | ✅ | ✅ |
metadata | ✅ — caller-set metadata from the original session | ✅ — same metadata as the original session (NOT the refund's metadata.merchant_order_id) | ✅ | ✅ |
checkout_session_external_id | — | — | — | ✅ |
merchant_order_id (top-level on refunds) | — | ✅ | — | — |
metadata carries through unchanged from the POST /api/v1/checkout-sessions call you made — that's how storefront plugins (WC / Give / Magento / Shopify) correlate webhooks back to the original order. Set whatever you need (an order id, a SKU, a campaign tag) at session-create time and you'll get it back on every downstream event.
Subscription-cycle events
subscription.charged and subscription.cancelled carry the same shape as payment.* plus a subscription block:
"subscription": {
"external_id": "sub_abc123",
"plan_id": "plan_monthly_pro",
"cycle": 7,
"next_billing_date": "2026-06-12"
}Checkout-session events
checkout.session.expired and checkout.cancelled skip the transaction_id field — there isn't one because no charge was attempted. Use them to clear orphan pending rows in your storefront:
{
"event_id": "evt_xyz",
"event_type": "checkout.session.expired",
"payload_redacted": {
"checkout_session_external_id": "cs_abc123",
"amount": 2500,
"currency": "USD",
"metadata": { "order_id": "1042", "source": "give" }
}
}Signature Verification
Every webhook delivery includes four headers:
| Header | Purpose |
|---|---|
X-GC-Signature | The HMAC-SHA256 signature, hex-encoded |
X-GC-Timestamp | Unix timestamp of the delivery attempt |
X-GC-Event-ID | Unique event id (evt_…) — also in the body, useful for log search |
X-GC-Event-Type | The event type (e.g. payment.completed) |
Compute the expected signature server-side and constant-time compare:
expected = HMAC-SHA256("{timestamp}.{payload}", webhook_secret)A complete PHP example is in Code Samples → Webhook Handler.
Reject stale events
Reject events with a X-GC-Timestamp more than 5 minutes off the wall clock. This blocks replay attacks even if a signature ever leaks.
Secret rotation
Each webhook endpoint has a current and a previous secret. To rotate:
- Dashboard → Webhooks → Endpoint → Rotate secret. A new secret is generated; the old one becomes the "previous" secret.
- New deliveries are signed with the current secret. Replays of pre-rotation events are signed with the previous secret (the platform tracks
secret_versionper delivery). - Your receiver should accept either signature during a transition window — try the current secret first, fall back to the previous on mismatch.
- After all in-flight deliveries you care about have drained (24h is a safe ceiling), revoke the previous secret from the dashboard.
Retry Schedule
Failed deliveries retry with exponential backoff:
1m → 5m → 30m → 2h → 12h → 24hUp to 6 attempts per event. After exhaustion the event is marked failed in the dashboard and you can replay it manually.
A delivery is considered failed if your endpoint returns any non-2xx response or times out (10 second limit).
Idempotency on the receiver
Make your handler idempotent on event_id (or transaction_id for payment events). The same event can deliver more than once — for example, when our retry logic and your server respond at the same time.
