Docs
Dashboard

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.

Idempotency

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.

EventDescription
checkout.session.completedA hosted checkout session was paid (card confirmed by Stripe, or crypto verified on-chain). Fulfil orders on this.
checkout.session.expiredA hosted checkout session passed its 30-minute window unpaid
checkout.session.payment_failedStripe reported a failed card attempt for a session (the session stays open)
payment.succeededA card payment succeeded (Stripe)
payment.failedA card payment failed (Stripe)
payment.refundedA payment was fully or partially refunded
order.fulfilledAn order was marked as fulfilled
order.cancelledAn order was cancelled
x402.payment.receivedAn x402 payment was verified on-chain and a receipt issued
payout.paidA payout was completed
payout.failedA payout failed

Payload Format

Every webhook event follows the same envelope:

Webhook Event Type
interface WebhookEvent {
id: string;        // Event id: the idempotency key, also sent as X-GullyPay-Event-Id
type: string;      // Event type, e.g. "checkout.session.completed"
created: string;   // ISO 8601 timestamp of the first delivery attempt
data: unknown;     // checkout.session.* and x402.payment.received: { object: {...} }
}

Checkout Session Events

checkout.session.completed, checkout.session.expired and checkout.session.payment_failed all carry the session in data.object:

checkout.session.completed
{
"id": "6f1c2b9e-4d0a-5c3b-9e8f-1a2b3c4d5e6f",
"type": "checkout.session.completed",
"created": "2026-10-25T12:00:03.120Z",
"data": {
  "object": {
    "object": "checkout.session",
    "id": "3f2b8c1e-9a4d-4e7b-8c21-0d5e6f7a8b9c",
    "status": "completed",
    "storeId": "8a1d…",
    "merchantId": "c7e2…",
    "amount": 4999,
    "currency": "USD",
    "clientReferenceId": "order_1042",
    "customerEmail": "[email protected]",
    "paymentMethod": "crypto",
    "transactionId": "d41f…",
    "crypto": { "chain": "base", "token": "USDC", "txHash": "0x9f…" },
    "lineItems": null,
    "failureReason": null,
    "successUrl": "https://yoursite.com/success",
    "cancelUrl": "https://yoursite.com/cancel",
    "expiresAt": "2026-10-25T12:29:58.000Z",
    "createdAt": "2026-10-25T11:59:58.000Z",
    "occurredAt": "2026-10-25T12:00:03.118Z"
  }
}
}
FieldTypeDescription
objectstringAlways checkout.session
idstringSession id (the one in the checkout link)
statusstringcompleted, expired or open (for payment_failed)
storeId / merchantIdstringOwning store and merchant
amountintegerAmount in cents
currencystringISO currency code
clientReferenceIdstring | nullYour order id from session creation (or from the checkout token)
customerEmailstring | nullCustomer email, when known
paymentMethodstring | nullcard or crypto once known (or what the checkout page reported)
transactionIdstring | nullGullyPay transaction that paid the session
cryptoobject | null{ chain, token, txHash } for crypto payments
lineItemsarray | nullFor storefront checkouts: { productId, variantId, title, quantity, unitAmount } priced by GullyPay
failureReasonstring | nullStripe's message, on payment_failed only
expiresAt / createdAtstringSession window
occurredAtstringWhen the event happened (for expired: the expiry time)

Delivery guarantees for checkout events:

  • One event per session and type. The event id is derived from the session id and event type, so checkout.session.completed for 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_failed has 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 completed can arrive after expired. Treat completed as final.
  • checkout.session.expired is sent for sessions that expired in the last 24 hours (sessions that expired earlier than that are closed silently).

x402.payment.received

x402.payment.received (data)
{
"object": {
  "object": "x402.payment",
  "id": "b3a1…",
  "endpointId": "e9c4…",
  "resource": "/api/premium-data",
  "network": "base",
  "asset": "USDC",
  "amount": "10000",
  "txHash": "0x…",
  "payerAddress": "0x…",
  "verifiedAt": "2026-10-25T12:00:00.000Z",
  "receiptExpiresAt": "2026-10-25T13:00:00.000Z"
}
}

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

verify-webhook.ts
1import crypto from 'crypto';
2
3function verifyWebhookSignature(
4payload: string,
5signature: string,
6secret: string,
7): boolean {
8const expectedSignature = crypto
9  .createHmac('sha256', secret)
10  .update(payload, 'utf8')
11  .digest('hex');
12
13// Use timing-safe comparison to prevent timing attacks
14const expected = Buffer.from(expectedSignature, 'hex');
15const received = Buffer.from(signature, 'hex');
16
17if (expected.length !== received.length) return false;
18
19return crypto.timingSafeEqual(expected, received);
20}
Always Verify Signatures

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:

webhook-handler.ts
1import express from 'express';
2import crypto from 'crypto';
3
4const app = express();
5
6// Use raw body for signature verification
7app.post(
8'/webhooks/gullypay',
9express.raw({ type: 'application/json' }),
10async (req, res) => {
11  const payload = req.body.toString();
12  const signature = req.headers['x-gullypay-signature'] as string;
13  const secret = process.env.GULLYPAY_WEBHOOK_SECRET!;
14
15  // 1. Verify signature
16  const expectedSig = crypto
17    .createHmac('sha256', secret)
18    .update(payload, 'utf8')
19    .digest('hex');
20
21  const expected = Buffer.from(expectedSig, 'hex');
22  const received = Buffer.from(signature || '', 'hex');
23
24  if (
25    expected.length !== received.length ||
26    !crypto.timingSafeEqual(expected, received)
27  ) {
28    console.error('Webhook signature verification failed');
29    return res.status(400).json({ error: 'Invalid signature' });
30  }
31
32  // 2. Parse the event
33  const event = JSON.parse(payload);
34
35  // 3. Handle the event (idempotently)
36  switch (event.type) {
37    case 'checkout.session.completed': {
38      const session = event.data.object;
39      // Fulfil the order. Dedupe on event.id: the same event can be retried.
40      console.log(`Session ${session.id} paid: ${session.amount} ${session.currency} (ref ${session.clientReferenceId})`);
41      break;
42    }
43
44    case 'checkout.session.expired': {
45      const session = event.data.object;
46      console.log(`Session ${session.id} expired unpaid`);
47      // Release reserved stock. A late crypto payment can still complete it.
48      break;
49    }
50
51    case 'payment.refunded': {
52      const refund = event.data;
53      console.log(`Payment ${refund.id} refunded: ${refund.amount}`);
54      break;
55    }
56
57    case 'payout.paid': {
58      const payout = event.data;
59      console.log(`Payout ${payout.id} paid: ${payout.amount}`);
60      // Update payout records
61      break;
62    }
63
64    case 'payout.failed': {
65      const payout = event.data;
66      console.log(`Payout ${payout.id} failed`);
67      // Alert merchant, retry logic, etc.
68      break;
69    }
70
71    default:
72      console.log(`Unhandled event type: ${event.type}`);
73  }
74
75  // 4. Respond with 200 to acknowledge receipt
76  res.status(200).json({ received: true });
77},
78);
79
80app.listen(3000, () => console.log('Webhook server running on port 3000'));

Python Webhook Handler

webhook_handler.py
1import hmac
2import hashlib
3import json
4import os
5from flask import Flask, request, jsonify
6
7app = Flask(__name__)
8WEBHOOK_SECRET = os.environ["GULLYPAY_WEBHOOK_SECRET"]  # the endpoint's signing secret
9
10def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
11  expected = hmac.new(
12      secret.encode("utf-8"),
13      payload,
14      hashlib.sha256,
15  ).hexdigest()
16  return hmac.compare_digest(expected, signature)
17
18@app.route("/webhooks/gullypay", methods=["POST"])
19def handle_webhook():
20  payload = request.get_data()
21  signature = request.headers.get("x-gullypay-signature", "")
22
23  if not verify_signature(payload, signature, WEBHOOK_SECRET):
24      return jsonify({"error": "Invalid signature"}), 400
25
26  event = json.loads(payload)
27
28  if event["type"] == "checkout.session.completed":
29      payment = event["data"]["object"]
30      print(f"Session {payment['id']} paid")
31
32  elif event["type"] == "payment.refunded":
33      refund = event["data"]
34      print(f"Refund processed for {refund['id']}")
35
36  return jsonify({"received": True}), 200

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):

AttemptDelay after previous failure
Initial delivery—
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry24 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:

HeaderDescription
X-GullyPay-EventThe event type (e.g. payment.succeeded)
X-GullyPay-Event-IdStable event id — identical across every retry of the same delivery. Use it as your idempotency key.
X-GullyPay-SignatureHMAC-SHA256 of the raw body (see Signature Verification). Recomputed from the same payload on each retry, so it stays valid.
X-GullyPay-AttemptThe 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:

Manual retry
# Retry a single delivery immediately
curl -X POST https://gullypay.xyz/api/developer/webhooks/deliveries/{deliveryId}/retry \
-H "Authorization: Bearer <jwt>"

# List recent delivery attempts for a subscription
curl https://gullypay.xyz/api/developer/webhooks/{subscriptionId}/deliveries \
-H "Authorization: Bearer <jwt>"
Respond Quickly

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:

Trigger Test Event
curl -X POST https://gullypay.xyz/api/webhooks/test \
-H "Authorization: Bearer rk_test_your_api_key" \
-H "Content-Type: application/json" \
-d '{"type": "payment_intent.succeeded"}'

Local Development

Use a tunnel service to receive webhooks on your local machine during development:

Terminal
# Using ngrok
ngrok http 3000

# Then configure your webhook URL in the Developer Dashboard:
# https://abc123.ngrok.io/webhooks/gullypay

Best Practices

  1. Always verify signatures -- never process unverified payloads.
  2. Make handlers idempotent -- use the event id to deduplicate. The same event may be delivered more than once.
  3. Respond quickly -- return 200 immediately, then process asynchronously.
  4. Log all events -- store raw webhook payloads for debugging and audit trails.
  5. Handle unknown events gracefully -- new event types may be added. Return 200 for events you do not handle to prevent retries.
  6. 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