When a payout order has a callback_url, GifQ POSTs JSON events to it as each recipient moves through the crypto payout lifecycle. Every request carries a unique delivery ID so you can deduplicate retries.
Endpoint contract
We POST to your registered callback_url:
POST <your callback_url>
Content-Type: application/json
- We send JSON only.
- We never include a query string.
- We do not follow redirects.
- Respond with any
2xxstatus code to acknowledge receipt. Any other response (including3xx) is treated as a failure and retried.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | GifQ-Webhooks/1.0 |
X-Gifq-Event | The event name. Also present as event in the body. |
X-Gifq-Delivery | UUID unique to this delivery attempt. Use this to deduplicate retries. |
X-Gifq-Timestamp | Unix epoch seconds at dispatch time. |
Delivery guarantees
At-least-once deliveryA single event may be delivered more than once if your acknowledgement is delayed or fails. Always deduplicate on
X-Gifq-Delivery.
- Up to 8 attempts. Failed deliveries are retried with exponential backoff. After the final attempt, the event is marked permanently failed and can be re-sent from the GifQ dashboard.
- Ordering is not guaranteed. Use
occurred_aton the body to resolve out-of-order arrivals. - Terminal events supersede intermediate ones. Once
payout.terminal_stateis sent for aexternal_send_id, any pendingpayout.status_changedfor the same send is dropped — you will not see stale intermediate states after the terminal event.
Common body fields
Every event body carries these fields. Event-specific additions are listed under each event below.
| Name | Type | Value |
|---|---|---|
event | String | The event name. One of payout.quote_created, payout.quote_confirmed, payout.quote_canceled, payout.status_changed, payout.terminal_state. |
payout_order_uuid | String | UUID of the payout order this event belongs to. Stable across retries. |
recipient_email | String | Email of the recipient whose payout this event describes. |
status | String | Current status of the underlying send. See Status values. |
occurred_at | String | ISO-8601 timestamp at which the event was generated. |
external_send_id | String | Identifier for the underlying crypto send. A single recipient can produce multiple external_send_ids if a quote expires and is reopened — treat each one as a separate lifecycle. |
tx_hash | String (optional) | Present on payout.terminal_state once a completed send is confirmed on chain. Omitted otherwise. |
failure_reason | String (optional) | Present on payout.terminal_state when status == "failed". Omitted otherwise. |
data | Hash (optional) | Full send-request snapshot — FX rate, fees, ledger account, beneficiary wallet, blockchain transactions. Present on payout.quote_* and payout.terminal_state events; omitted on payout.status_changed. See data shape. |
Events
payout.quote_created
payout.quote_createdThe recipient has selected a currency and network and an FX quote has been issued. This is the earliest event you receive for a given external_send_id.
| Name | Type | Value |
|---|---|---|
| common fields | See Common body fields. | |
data | Hash | Quote details. See data shape. |
{
"event": "payout.quote_created",
"status": "in_progress",
"occurred_at": "2026-05-25T10:00:00Z",
"recipient_email": "[email protected]",
"external_send_id": "123456",
"payout_order_uuid": "11111111-2222-3333-4444-555555555555",
"data": {
"id": 123456,
"status": "in_progress",
"purpose": "Gifq payout to [email protected]",
"created_at": "2026-05-25T10:00:00.000Z",
"input_amount": "100.00",
"input_currency": {
"id": 33,
"kind": "crypto",
"title": "USDC",
"symbol": "USDC",
"enabled": true,
"disabled_message": null
},
"sending_amount": "100.00",
"sending_currency": {
"id": 33,
"kind": "crypto",
"title": "USDC",
"symbol": "USDC",
"enabled": true,
"disabled_message": null
},
"input_to_sending_rate": "1.0",
"balance_debit_amount": "0.047778",
"balance_debit_currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
},
"sending_to_balance_debit_rate": "2092.89",
"fees": {
"service_fee": {
"amount": "0.00056141",
"currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
}
},
"conversion_fee": {
"amount": "0.000478",
"currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
}
}
},
"ledger_account": {
"id": "01EXAMPLELEDGER0000000000000",
"title": "Ethereum main account",
"status": "active",
"balance": "10.000000000000000000",
"account_type": "main",
"currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
}
},
"beneficiary_payout_setting": {
"id": 999,
"beneficiary_id": 888,
"created_at": "2026-05-25T09:59:59.000Z",
"currency": {
"id": 33,
"kind": "crypto",
"title": "USDC",
"symbol": "USDC",
"enabled": true,
"disabled_message": null
},
"platform": {
"id": 21,
"title": "Solana",
"id_name": "solana"
},
"crypto_address": "ExampleSolanaWalletAddress00000000000000000",
"crypto_address_metadata": null
},
"blockchain_transactions": [],
"requires_2fa_confirmation": false
}
}
No duplicates on retryIdempotent retries of the underlying quote action do not produce duplicate
payout.quote_createdevents for the sameexternal_send_id.
payout.quote_confirmed
payout.quote_confirmedThe recipient confirmed the FX exchange on a cross-currency quote. Fires only when an FX exchange actually occurred — same-currency quotes do not produce this event.
| Name | Type | Value |
|---|---|---|
| common fields | See Common body fields. | |
data | Hash | { "remote": <confirm response> }. |
payout.quote_canceled
payout.quote_canceledThe recipient canceled an open quote. The same external_send_id cannot be re-confirmed; a subsequent quote produces a new external_send_id and a fresh payout.quote_created.
| Name | Type | Value |
|---|---|---|
| common fields | See Common body fields. | |
data | Hash | { "remote": <cancel response> }. |
payout.status_changed
payout.status_changedA non-terminal status change on the underlying send. You may see one or more of these between payout.quote_created and payout.terminal_state. Treat occurred_at as authoritative for ordering.
| Name | Type | Value |
|---|---|---|
| common fields | See Common body fields. |
{
"event": "payout.status_changed",
"status": "processing",
"occurred_at": "2026-05-25T10:01:32Z",
"recipient_email": "[email protected]",
"external_send_id": "123456",
"payout_order_uuid": "11111111-2222-3333-4444-555555555555"
}payout.terminal_state
payout.terminal_stateThe underlying send has reached a terminal status (completed, failed, expired, or canceled). You receive at most one payout.terminal_state per external_send_id.
tx_hashmay arrive on a follow-up eventIf a send is
completedbut the on-chain transaction has not yet confirmed at dispatch time,tx_hashis omitted. A secondpayout.terminal_statefor the sameexternal_send_idmay follow once the hash lands — treat the later event as authoritative fortx_hash.
| Name | Type | Value |
|---|---|---|
| common fields | See Common body fields. | |
tx_hash | String (optional) | Present when status == "completed" and the on-chain confirmation has landed. |
failure_reason | String (optional) | Present when status == "failed". |
Completed example:
{
"event": "payout.terminal_state",
"status": "completed",
"occurred_at": "2026-05-25T10:04:17Z",
"recipient_email": "[email protected]",
"external_send_id": "123456",
"payout_order_uuid": "11111111-2222-3333-4444-555555555555",
"tx_hash": "0x1111111111111111111111111111111111111111111111111111111111111111",
"data": {
"id": 123456,
"status": "completed",
"purpose": "Gifq payout to [email protected]",
"created_at": "2026-05-25T10:00:00.000Z",
"input_amount": "100.00",
"input_currency": {
"id": 33,
"kind": "crypto",
"title": "USDC",
"symbol": "USDC",
"enabled": true,
"disabled_message": null
},
"sending_amount": "100.00",
"sending_currency": {
"id": 33,
"kind": "crypto",
"title": "USDC",
"symbol": "USDC",
"enabled": true,
"disabled_message": null
},
"input_to_sending_rate": "1.0",
"balance_debit_amount": "0.047778",
"balance_debit_currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
},
"sending_to_balance_debit_rate": "2092.89",
"fees": {
"service_fee": {
"amount": "0.00056141",
"currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
}
},
"conversion_fee": {
"amount": "0.000478",
"currency": {
"id": 5,
"kind": "crypto",
"title": "Ethereum",
"symbol": "ETH",
"enabled": true,
"disabled_message": null
}
}
},
"blockchain_transactions": [
{
"id": 7000001,
"txid": "0x1111111111111111111111111111111111111111111111111111111111111111",
"amount": "100.00",
"status": "confirmed",
"network_confirmations": 1,
"currency": {
"id": 33,
"title": "USDC",
"symbol": "USDC",
"platform": {
"id": 21,
"title": "Solana"
}
}
}
],
"requires_2fa_confirmation": false
}
}Failed example:
{
"event": "payout.terminal_state",
"status": "failed",
"occurred_at": "2026-05-25T10:04:17Z",
"recipient_email": "[email protected]",
"external_send_id": "123456",
"payout_order_uuid": "11111111-2222-3333-4444-555555555555",
"failure_reason": "insufficient_funds"
}Status values
status | Meaning |
|---|---|
in_progress | The send request has been accepted. No funds have moved yet. |
processing | The send is being processed. An FX exchange may be in flight or the chain broadcast is pending. |
completed | The send is finalized. If tx_hash is present, funds are confirmed on chain. |
failed | The send did not go through. failure_reason carries the cause. |
expired | The send was not actioned within its lifetime window. |
canceled | The send was canceled. No funds moved. |
completed, failed, expired, and canceled are terminal — no further events fire for that external_send_id once a terminal event has been sent.
data shape
data shapedata is present on payout.quote_* events and on payout.terminal_state events. It carries the full send-request snapshot at the moment the event was generated.
| Field | Type | Notes |
|---|---|---|
id | Integer | Send-request ID. Equals external_send_id parsed as integer. |
status | String | Current status of the underlying send. Mirrors the top-level status. |
purpose | String | Human-readable description of the payout. |
created_at | String | ISO-8601 timestamp when the send request was created. |
input_amount | String | Amount the recipient is sending, in input_currency. |
input_currency | Hash | See Currency object. |
sending_amount | String | Amount the recipient will receive, in sending_currency. |
sending_currency | Hash | See Currency object. |
input_to_sending_rate | String | FX rate used to convert input_amount to sending_amount. "1.0" for same-currency sends. |
balance_debit_amount | String | Amount debited from the merchant's ledger, in balance_debit_currency. |
balance_debit_currency | Hash | See Currency object. |
sending_to_balance_debit_rate | String | FX rate used to convert from sending_currency to balance_debit_currency. |
fees.service_fee | Hash | { amount, currency }. amount is a decimal string; currency follows the Currency object shape. |
fees.conversion_fee | Hash | { amount, currency }. Only present on cross-currency sends. |
ledger_account | Hash | { id, title, status, balance, account_type, currency }. The merchant ledger account funds were debited from. |
beneficiary_payout_setting | Hash | { id, beneficiary_id, created_at, currency, platform, crypto_address, crypto_address_metadata }. The destination wallet for this send. |
blockchain_transactions | Array | Empty [] until the chain broadcast lands. Once populated, each entry is { id, txid, amount, status, network_confirmations, currency }. The txid of the first entry is surfaced at the top level as tx_hash on payout.terminal_state. |
requires_2fa_confirmation | Boolean | Whether the recipient must complete 2FA before the send proceeds. |
Currency object
input_currency, sending_currency, balance_debit_currency, and the currency nested under fees.* / ledger_account / beneficiary_payout_setting all share the same shape:
| Field | Type | Notes |
|---|---|---|
id | Integer | Internal currency ID. |
kind | String | "fiat" or "crypto". |
title | String | Full currency name (e.g. "Ethereum", "USDC"). |
symbol | String | Ticker symbol (e.g. "ETH", "USDC"). |
enabled | Boolean | Whether the currency is currently enabled for new sends. |
disabled_message | String (optional) | Reason a disabled currency is unavailable. null when enabled: true. |
BlockchainTransaction object
Entries in data.blockchain_transactions[] describe individual on-chain transactions backing the send. The array is empty until the chain broadcast lands; once populated, the txid of the first entry is surfaced at the top level as tx_hash on payout.terminal_state.
| Field | Type | Notes |
|---|---|---|
id | Integer | Internal transaction ID. |
txid | String | On-chain transaction hash. |
amount | String | Decimal amount transferred in this transaction. |
status | String | Transaction status (e.g. "confirmed"). |
network_confirmations | Integer | Number of confirmations the transaction has received. |
currency | Hash | A thinner variant of the Currency object: { id, title, symbol, platform: { id, title } }. The nested platform identifies the chain the transaction was broadcast on (e.g. "Base", "Solana", "Ethereum"). |
Example:
{
"id": 7000001,
"txid": "0x1111111111111111111111111111111111111111111111111111111111111111",
"amount": "12.0",
"status": "confirmed",
"network_confirmations": 1,
"currency": {
"id": 113,
"title": "USDC",
"symbol": "USDC",
"platform": {
"id": 42,
"title": "Base"
}
}
}
Decimal amounts are stringsAll amounts are decimal strings — parse with your language's arbitrary-precision decimal type, not floats. Additional fields may appear in
data; treat unknown fields as informational.
Receiving in your handler
Recommended order:
- Deduplicate on
X-Gifq-Delivery. - Handle the event.
- Acknowledge with
2xxonly after durable persistence.
post '/gifq/webhooks' do
body = JSON.parse(request.body.read)
delivery_id = request.env['HTTP_X_GIFQ_DELIVERY']
return status 204 if already_handled?(delivery_id)
case body['event']
when 'payout.quote_created' then on_quote_created(body)
when 'payout.terminal_state' then on_terminal(body)
# ...
end
record_handled(delivery_id)
status 204
end