How GIFQ sends webhook events, the two body shapes, signature verification, and delivery behaviour.
GIFQ sends your backend an HTTP POST with a JSON body when a payout moves through its lifecycle, so you do not have to poll our API.
Everything on this page applies to every event. For the list of event names see Events reference. For individual payloads see Payout events and Recipient events. For what happens when a delivery fails, see Webhook retry handling.
Subscribing
You subscribe per payout order, by passing a callback_url when you create it.
POST /api/payout-orders
X-Api-Token: <your API token>
{
"campaign_id": "…",
"wallet_currency": "GBP",
"recipients": [ … ],
"callback_url": "https://merchant.example.com/webhooks/gifq"
}callback_url must be HTTPS. An order without one fires no webhooks at all, for either payout type.
There is no account-level webhook URL and no per-event subscription. Every event fires for every order that has a callback_url.
One event per record, not per orderEach event reports one record changing state. None of them report the order's overall status. An order with 50 recipients produces at least 50 separate event streams, plus one more for every gift card a recipient redeems.
Endpoint contract
POST <your callback_url>
Content-Type: application/json
We send JSON only, never a query string, and we do not follow redirects.
Respond 2xx to acknowledge. Every other status counts as a failure, including 3xx. The read timeout is 10 seconds, so acknowledge first and do the work afterwards.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | GIFQ-Webhooks/1.0 |
X-Gifq-Event | The event name. Also in the body as event. |
X-Gifq-Signature | sha256=<hex digest>, an HMAC-SHA256 of the raw request body. See Verifying signatures. |
X-Gifq-Delivery | A UUID for this one attempt. Regenerated on every retry. |
X-Gifq-Timestamp | Unix epoch seconds at dispatch. |
Neither of these is an idempotency key
X-Gifq-Deliverychanges on every attempt, so a retry of an event you already handled arrives with a different UUID. Storing it will not catch duplicates. Build your key from the body instead, using the fields in Webhook retry handling.
X-Gifq-Timestampis not covered by the signature, so it is unauthenticated. Do not use it to enforce a replay window.
Two body shapes
GIFQ sends two structurally different bodies. Which one you get depends on payout_type, not on the event name.
Route on event and payout_type together. payout.processing and payout.failed each fire for both payout types, with different bodies.
switch (`${body.event}:${body.payout_type}`) {
case 'payout.processing:crypto': return onCryptoProcessing(body.data);
case 'payout.processing:gift_cards': return onCardProcessing(body);
}The GIFQ envelope (gift cards only)
A gift_cards body is one flat object. There is no data key. Here is a complete payout.processing delivery for a £25 card:
{
"event": "payout.processing",
"payout_order_uuid": "11111111-2222-3333-4444-555555555555",
"recipient_email": "[email protected]",
"recipient_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"recipient_name": "Alice Doe",
"subject_type": "payout",
"status": "processing",
"updated_at": "2026-05-21T10:04:50Z",
"payout_type": "gift_cards",
"currency": "GBP",
"sent_at": "2026-05-20T09:00:00Z",
"viewed_at": "2026-05-21T10:00:00Z",
"expires_at": "2026-08-20T09:00:00Z",
"payout_uuid": "99999999-8888-7777-6666-555555555555",
"brand_uuid": "77777777-6666-5555-4444-333333333333",
"brand_name": "Amazon UK",
"amount": 25.0
}These keys are on every gift_cards body. Event pages document the extra keys they add on top, and no event sends a key as null just because it does not apply.
| Field | Type | Value |
|---|---|---|
event | String | Event name, <domain>.<action>. Mirrors X-Gifq-Event. |
payout_type | String | gift_cards on these bodies. |
subject_type | String | recipient or payout, naming which record changed. |
status | String | Current status of that record. Each subject type has its own values. |
updated_at | String | ISO-8601. Use this to order events. |
payout_order_uuid | String | The parent payout order. |
recipient_uuid | String | The recipient this event concerns. |
recipient_email | String | Recipient's email. |
recipient_name | String | Recipient's name. |
sent_at | String | null | ISO-8601, when the claim link was sent. |
viewed_at | String | null | ISO-8601, when the recipient first opened it. |
expires_at | String | null | ISO-8601, the entitlement deadline. |
Gift-card bodies never contain the cardThe code, PIN, and serial number are never in a webhook body. They reach the recipient through the redeem flow and nowhere else. You cannot get them from here.
Crypto bodies
A crypto body has three keys: event, payout_type, and data. None of the gift-card keys above appear. Everything specific to the send lives inside data, which is our crypto provider's send request passed through as we received it.
{
"event": "payout.processing",
"payout_type": "crypto",
"data": {
"id": 354,
"status": "confirming",
"input_amount": "100.0",
"sending_amount": "0.049422",
"blockchain_transactions": [],
"requires_2fa_confirmation": false
}
}data.status is the provider's own string, not GIFQ's. A provider confirming arrives as payout.processing with data.status still "confirming". payout.quote_created documents the full data object once, since the shape is the same on every crypto event.
Amounts
Amounts are typed differently in each body shape.
gift_cardsbodies send JSON numbers, such as25.0.- Amounts inside a crypto
dataobject are decimal strings, such as"0.047778". Parse them with an arbitrary-precision decimal type. A float will lose precision.
Verifying signatures
Every delivery is signed with a per-account secret. Verify it before you trust a body.
Get your signing secret
GET /api/webhooks
X-Api-Token: <your API token>{ "webhooks": [ { "signature": "jXjdU8dozx571TG6YzL4…" } ] }The secret is created on first read, so this never 404s. Calling it before your first webhook returns the same value we will sign with. There is no rotation or dual-secret rollover today.
The scheme
X-Gifq-Signature is sha256= followed by the HMAC-SHA256 hex digest of the raw request body, keyed on your signing secret. The body is the whole signed message. No timestamp, no path.
Sign the raw bytes, not re-serialized JSONThe signature covers the exact bytes GIFQ sent. If your framework parses the JSON before your handler runs and you rebuild the digest from
JSON.stringifyor.to_json, verification will fail, because key order and whitespace will not match.In Rails, read
request.raw_postbefore touchingparams. In Express, mountexpress.raw({ type: 'application/json' })on the webhook route instead of the globalexpress.json():app.post('/webhooks/gifq', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); // untouched bytes, do not parse and re-serialize // verify against `raw`, then JSON.parse it });
If the signatures do not match, reject the delivery and do not process it.
Delivery
| Guarantee | At least once. An event can arrive more than once. |
| Retries | Up to 8 attempts, spread over roughly 90 minutes. |
| Retried on | Connection failures, timeouts, and your 5xx. |
| Not retried | Your 4xx. The event is abandoned on the first one. |
| Timeouts | Connect 5s, read 10s. |
| Rate | 5 deliveries per second per account. Excess is delayed, not dropped. |
Order is not guaranteed. Deliveries are rate-limited and retried independently, so events can arrive in any order. Sort on updated_at from the body rather than on arrival time.
Webhook retry handling covers the backoff schedule, what makes an event permanently fail, and how to deduplicate.
Recommended handler
- Read the raw body.
- Verify
X-Gifq-Signatureand reject on mismatch. - Build an idempotency key from the body and skip keys you have already handled.
- Route on
eventandpayout_type. - Store the event, then return
2xx.
# Rails. Raw body first, verify, then parse.
class GifqWebhooksController < ApplicationController
skip_forgery_protection
def create
raw = request.raw_post
return head :unauthorized unless gifq_signature_valid?(
raw, request.headers['X-Gifq-Signature'], ENV.fetch('GIFQ_WEBHOOK_SECRET')
)
body = JSON.parse(raw)
key = gifq_idempotency_key(body)
return head :no_content if WebhookReceipt.exists?(idempotency_key: key)
Gifq::HandleEvent.call(body) # route on body['event'] and body['payout_type']
WebhookReceipt.create!(idempotency_key: key)
head :no_content
end
private
# X-Gifq-Delivery changes on every retry, so the key comes from the body.
def gifq_idempotency_key(body)
case body['payout_type']
when 'gift_cards'
[body['event'], body['subject_type'], body['recipient_uuid'], body['updated_at']].join(':')
when 'crypto'
[body['event'], body.dig('data', 'id')].join(':')
end
end
endEnforce uniqueness with a unique index on idempotency_key and rescue the conflict. Two retries of the same event can reach two of your workers at once, and a read-then-write check will let both through.
Event naming and versioning
Event names are <domain>.<action>, lowercase and dot-separated. The domains today are payout and recipient. The event field in the body and the X-Gifq-Event header always match.
Changes are additive. We add optional fields, new status values, and new event types. We do not change what an existing payload means, and a breaking change ships under a new event name.
Ignore fields you do not recognise, and ignore event values you do not recognise. That is what lets us extend the API without breaking your integration.