Webhooks
Receive real-time event notifications when something happens in your GullyPay account. Webhooks are HTTP POST requests sent to your server whenever a payment succeeds, an order is created, a payout completes, or any other significant event occurs.
Overview
Instead of polling the API to check for updates, configure a webhook endpoint to receive push notifications. GullyPay will send a JSON payload to your URL with the event details.
Webhook deliveries are automatically retried on failure, so your handler must
be idempotent. Deduplicate on the X-GullyPay-Event-Id header (or the payload's
id field) — it stays identical across every retry of the same delivery. See
Retry Policy for the full backoff schedule.
Event Types
These are the events GullyPay sends to your endpoints. Subscribe per endpoint in the Developer
Dashboard (Developers → Webhooks), or use * for all of them.
| Event | Description |
|---|---|
checkout.session.completed | A hosted checkout session was paid (card confirmed by Stripe, or crypto verified on-chain). Fulfil orders on this. |
checkout.session.expired | A hosted checkout session passed its 30-minute window unpaid |
checkout.session.payment_failed | Stripe reported a failed card attempt for a session (the session stays open) |
payment.succeeded | A card payment succeeded (Stripe) |
payment.failed | A card payment failed (Stripe) |
payment.refunded | A payment was fully or partially refunded |
order.fulfilled | An order was marked as fulfilled |
order.cancelled | An order was cancelled |
x402.payment.received | An x402 payment was verified on-chain and a receipt issued |
payout.paid | A payout was completed |
payout.failed | A payout failed |
Payload Format
Every webhook event follows the same envelope:
Checkout Session Events
checkout.session.completed, checkout.session.expired and checkout.session.payment_failed all carry
the session in data.object:
| Field | Type | Description |
|---|---|---|
object | string | Always checkout.session |
id | string | Session id (the one in the checkout link) |
status | string | completed, expired or open (for payment_failed) |
storeId / merchantId | string | Owning store and merchant |
amount | integer | Amount in cents |
currency | string | ISO currency code |
clientReferenceId | string | null | Your order id from session creation (or from the checkout token) |
customerEmail | string | null | Customer email, when known |
paymentMethod | string | null | card or crypto once known (or what the checkout page reported) |
transactionId | string | null | GullyPay transaction that paid the session |
crypto | object | null | { chain, token, txHash } for crypto payments |
lineItems | array | null | For storefront checkouts: { productId, variantId, title, quantity, unitAmount } priced by GullyPay |
failureReason | string | null | Stripe's message, on payment_failed only |
expiresAt / createdAt | string | Session window |
occurredAt | string | When the event happened (for expired: the expiry time) |
Delivery guarantees for checkout events:
- One event per session and type. The event
idis derived from the session id and event type, socheckout.session.completedfor a session always has the same id, and GullyPay does not deliver it to an endpoint twice even when a card payment is confirmed by both Stripe and the checkout page.payment_failedhas one id per Stripe event. - Retries follow the retry policy below with the same id, body and signature.
- Order: a crypto payment that verifies within 24 hours after expiry still completes the session, so
completedcan arrive afterexpired. Treatcompletedas final. checkout.session.expiredis sent for sessions that expired in the last 24 hours (sessions that expired earlier than that are closed silently).
x402.payment.received
amount is in the asset's base units. One event per network + transaction hash.
Other events
payment.*, order.* and payout.* events carry a flat data object, for example
{ "id": "<transactionId>", "amount": 2499, "currency": "USD", "storeId": "…" } for payment.succeeded.
Signature Verification
Every delivery carries an X-GullyPay-Signature header: the hex HMAC-SHA256 of the raw request body,
keyed with the endpoint's signing secret. The dashboard shows the signing secret once, right after you
create the endpoint (it is the subscription's secretHash, also returned by
POST /merchants/:merchantId/webhooks). Verify it on the raw body before parsing the JSON.
HMAC-SHA256 Verification
Never process webhook payloads without verifying the signature. Without verification, an attacker could forge webhook events to trigger unauthorized actions (e.g., marking unpaid orders as paid).
Webhook Handler Example
A complete Express.js webhook handler:
Python Webhook Handler
Retry Policy
If your endpoint does not return a 2xx status code within 10 seconds (a non-2xx
response, a connection error, or a timeout), GullyPay automatically retries the
delivery with exponential backoff. A delivery is attempted up to 6 times
(1 initial attempt + 5 retries):
| Attempt | Delay after previous failure |
|---|---|
| Initial delivery | — |
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 24 hours |
A delivery stays in the failed state (with a scheduled next_retry_at) while
retries remain. Once your endpoint returns a 2xx it is marked success and no
further attempts are made. If all 6 attempts fail the delivery is marked
exhausted and retries stop.
Every delivery attempt — successful or failed, with its response code, attempt
number (attempt/maxAttempts), scheduled next retry, and duration — is recorded
and visible in the Developer Dashboard under Webhooks. Expand any endpoint's
Delivery attempts panel to inspect its history and trigger an immediate
Retry now for any non-delivered attempt.
Retry headers & idempotency
Every delivery (including retries) sends these headers so you can dedupe safely:
| Header | Description |
|---|---|
X-GullyPay-Event | The event type (e.g. payment.succeeded) |
X-GullyPay-Event-Id | Stable event id — identical across every retry of the same delivery. Use it as your idempotency key. |
X-GullyPay-Signature | HMAC-SHA256 of the raw body (see Signature Verification). Recomputed from the same payload on each retry, so it stays valid. |
X-GullyPay-Attempt | The attempt number for this specific POST (1 = initial). |
The retried payload is byte-for-byte identical to the original (same id,
created, and data), so verifying the signature and deduplicating on
X-GullyPay-Event-Id (or the payload's id field) is sufficient.
Manual retry
You can force an immediate re-delivery of any non-delivered attempt, bypassing the backoff schedule:
Your webhook endpoint should return a 200 response within 10 seconds. If processing takes longer, acknowledge the webhook immediately and process the event asynchronously using a job queue (e.g., BullMQ, SQS).
Testing Webhooks
Sandbox Events
In the sandbox environment, you can trigger test events from the Developer Dashboard or via the API:
Local Development
Use a tunnel service to receive webhooks on your local machine during development:
Best Practices
- Always verify signatures -- never process unverified payloads.
- Make handlers idempotent -- use the event
idto deduplicate. The same event may be delivered more than once. - Respond quickly -- return
200immediately, then process asynchronously. - Log all events -- store raw webhook payloads for debugging and audit trails.
- Handle unknown events gracefully -- new event types may be added. Return
200for events you do not handle to prevent retries. - Use HTTPS -- webhook endpoints must use HTTPS in production.
Next Steps
- API Reference -- Complete endpoint documentation
- Getting Started -- Initial setup and first payment
- SDKs -- SDK documentation with built-in webhook verification helpers