Webhook Events
Complete reference for all webhook event types, payloads, and delivery behavior.
Bags sends webhook events to your registered endpoint when payment and subscription lifecycle events occur. All events are signed with HMAC-SHA256 for verification.
Event List
| Event | Category | Description |
|---|---|---|
checkout.completed | Checkout | Hosted checkout finished successfully (payment settled on-chain) |
checkout.failed | Checkout | On-chain transaction reverted or failed verification |
checkout.cancelled | Checkout | Customer cancelled the checkout before broadcast |
checkout.expired | Checkout | Checkout was not paid within its expiry window |
payment.refunded | Payment | A refund was processed against a completed transaction |
Focus on checkout.* events. These four events cover the complete lifecycle of a hosted checkout created via POST /api/v1/checkouts. Use checkout.completed to fulfill orders — it's your source of truth for successful payments.
Subscription events coming soon. Recurring billing with subscription.* events is on the roadmap. Join the waitlist for early access.
Common Envelope
Every webhook delivery follows this structure:
{
"event": "event.name",
"data": { ... },
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-01T12:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
event | string | Event type identifier |
data | object | Event-specific payload (see below) |
webhookDeliveryId | string | Unique delivery ID for idempotency |
timestamp | string | ISO 8601 timestamp of the event |
Sandbox events also include "livemode": false in the data object.
Checkout Events
These events cover the full lifecycle of a hosted checkout created via
POST /api/v1/checkouts.
They are the primary source of truth for checkout state — use them to fulfil
orders, release holds, and surface status in your UI.
checkout.completed
Fired when a checkout is fully paid and the on-chain transfer is verified.
{
"event": "checkout.completed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"txHash": "0xabc123def456...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": "29.99",
"network": "base_sepolia",
"livemode": false,
"productId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"externalCustomerId": "cus_123",
"customerId": "4c0a9f33-8g2g-5d4b-9g6b-3e8b1e4f0c2d",
"metadata": { "orderId": "ord_456" }
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-01T12:05:00.000Z"
}| Field | Type | Description |
|---|---|---|
sessionId | string | Checkout session ID (same as the id returned by POST /api/v1/checkouts) |
paymentLinkId | string | Ephemeral payment link that backed the checkout |
productId | string | Catalog product id, when the link was product-backed |
externalCustomerId | string | Your customer id, if supplied at session creation |
customerId | string | BAGS customer row id, when reconciled |
metadata | object | Opaque key/value metadata from session creation |
txHash | string | On-chain transaction hash |
merchantWalletAddress | string | Merchant's receiving wallet |
amount | string | Payment amount in USD, sent as a decimal-precise string |
network | string | base, base_sepolia, ethereum, polygon, or solana |
livemode | boolean | true for production, false for test mode |
Recommended action: Fulfil the order, grant access, send a receipt. The
checkout is now in a terminal complete state.
A matching payment.completed is emitted for backwards compatibility. Process
one of the two events (not both) to avoid double-fulfilment.
checkout.failed
Fired when an on-chain transfer is broadcast but fails verification — either the transaction reverted, the wrong amount was transferred, or the destination address doesn't match the merchant's wallet.
{
"event": "checkout.failed",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"txHash": "0xabc123def456...",
"network": "base_sepolia",
"amount": "29.99",
"reason": "INVALID_TRANSFER",
"message": "Transferred amount (10.00) does not match expected (29.99)",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-01T12:05:00.000Z"
}| Field | Type | Description |
|---|---|---|
sessionId | string | Checkout session ID |
paymentLinkId | string | Payment link that backed the checkout |
txHash | string | null | The reverted transaction, when one was broadcast |
network | string | Chain the transfer was attempted on |
amount | string | Expected amount in USD |
reason | string | Machine-readable failure code (e.g. INVALID_TRANSFER, TX_REVERTED) |
message | string | Human-readable explanation |
livemode | boolean | true for production, false for test mode |
Recommended action: Notify the customer, surface message, allow retry.
The checkout is in a terminal failed state and cannot be reused — issue a
fresh POST /api/v1/checkout if the customer wants to try again.
checkout.cancelled
Fired when the customer explicitly cancels the checkout from the hosted page (or you call the cancel endpoint on their behalf) before any on-chain transaction is broadcast. Cancels after broadcast are rejected by the API because funds may already be moving.
{
"event": "checkout.cancelled",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"network": "base_sepolia",
"amount": "29.99",
"reason": "customer_cancelled",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-01T12:07:00.000Z"
}| Field | Type | Description |
|---|---|---|
sessionId | string | Checkout session ID |
paymentLinkId | string | Payment link that backed the checkout |
network | string | Chain the customer had selected |
amount | string | Expected amount in USD |
reason | string | Always customer_cancelled in v0 |
livemode | boolean | true for production, false for test mode |
Recommended action: Release any inventory holds, mark the order as cancelled, and optionally offer a "resume" CTA (which should create a new checkout — the cancelled one is terminal).
checkout.expired
Fired by the expiry cron when a checkout passes its expiresAt without a
transaction being broadcast. Default expiry is 30 minutes from creation.
{
"event": "checkout.expired",
"data": {
"sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentLinkId": "3b9f8f22-7f1f-4c3a-8f5a-2d7a0d3e9b1c",
"network": "base_sepolia",
"amount": "29.99",
"expiresAt": "2026-04-01T13:00:00.000Z",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-01T13:00:12.000Z"
}| Field | Type | Description |
|---|---|---|
sessionId | string | Checkout session ID |
paymentLinkId | string | Payment link that backed the checkout |
network | string | Chain the customer had selected |
amount | string | Expected amount in USD |
expiresAt | string | ISO 8601 time the checkout was configured to expire |
livemode | boolean | true for production, false for test mode |
Recommended action: Release inventory holds, optionally email the customer
with a fresh checkout link. The expired checkout is terminal; do not reuse its
sessionId.
Expiry is evaluated every minute. The delivery timestamp may therefore be up
to ~60 seconds after expiresAt. A checkout that was broadcast just before
its expiry will still complete or fail through the normal payment events — the
cron deliberately skips sessions with a broadcast transaction.
Refund Events
payment.refunded
Fired when a refund is processed against a previously completed transaction —
either a full refund (reversing the entire payment) or a partial refund. Works
for both standalone transactions and transactions that came through a
checkout.* session.
{
"event": "payment.refunded",
"data": {
"refundId": "rfd_2f9b...",
"transactionId": "8c7e6d5f-1234-4abc-9def-0123456789ab",
"txHash": "0xrefund789...",
"merchantWalletAddress": "0xYourWalletAddress",
"amount": "29.99",
"subtotalAmount": "27.49",
"taxAmount": "2.50",
"feeReversed": "0.45",
"token": "USDC",
"network": "base_sepolia",
"reason": "customer_requested",
"fullyRefunded": true,
"initiatedBy": "merchant",
"livemode": false
},
"webhookDeliveryId": "d4e5f6a1-b2c3-7890-abcd-ef1234567890",
"timestamp": "2026-04-02T09:15:00.000Z"
}| Field | Type | Description |
|---|---|---|
refundId | string | Unique refund ID |
transactionId | string | Original transaction being refunded |
txHash | string | On-chain hash of the refund transfer |
merchantWalletAddress | string | Wallet the refund was sent from |
amount | string | Total refunded amount (decimal-precise string) |
subtotalAmount | string | Portion of amount that is subtotal (pre-tax) reversed |
taxAmount | string | Portion of amount that is tax reversed |
feeReversed | string | Platform fee reversed back to the merchant |
token | string | Stablecoin refunded (e.g. USDC) |
network | string | Chain the refund was broadcast on |
reason | string | null | Free-text reason supplied at refund time |
fullyRefunded | boolean | true if this refund zeroed the remaining refundable balance |
initiatedBy | string | merchant, api, admin, or customer |
livemode | boolean | true for production, false for test mode |
Recommended action: Reverse any fulfilment (revoke access, issue credit,
mark order refunded). If fullyRefunded is false, the transaction can still
receive further partial refunds up to the remaining balance.
Delivery and Retries
Bags retries failed deliveries up to 8 times with exponential backoff over ~24 hours:
| Attempt | Delay after failure |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
| 6 | ~5 hours |
| 7 | ~10 hours |
| 8 | ~24 hours |
After 8 failed attempts, the delivery is marked as failed. You can retry manually from the dashboard under Developer Settings > Webhooks.