Webhooks overview

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 order

Each 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

HeaderValue
Content-Typeapplication/json
User-AgentGIFQ-Webhooks/1.0
X-Gifq-EventThe event name. Also in the body as event.
X-Gifq-Signaturesha256=<hex digest>, an HMAC-SHA256 of the raw request body. See Verifying signatures.
X-Gifq-DeliveryA UUID for this one attempt. Regenerated on every retry.
X-Gifq-TimestampUnix epoch seconds at dispatch.
🚧

Neither of these is an idempotency key

X-Gifq-Delivery changes 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-Timestamp is 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.

FieldTypeValue
eventStringEvent name, <domain>.<action>. Mirrors X-Gifq-Event.
payout_typeStringgift_cards on these bodies.
subject_typeStringrecipient or payout, naming which record changed.
statusStringCurrent status of that record. Each subject type has its own values.
updated_atStringISO-8601. Use this to order events.
payout_order_uuidStringThe parent payout order.
recipient_uuidStringThe recipient this event concerns.
recipient_emailStringRecipient's email.
recipient_nameStringRecipient's name.
sent_atString | nullISO-8601, when the claim link was sent.
viewed_atString | nullISO-8601, when the recipient first opened it.
expires_atString | nullISO-8601, the entitlement deadline.
🚧

Gift-card bodies never contain the card

The 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_cards bodies send JSON numbers, such as 25.0.
  • Amounts inside a crypto data object 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 JSON

The signature covers the exact bytes GIFQ sent. If your framework parses the JSON before your handler runs and you rebuild the digest from JSON.stringify or .to_json, verification will fail, because key order and whitespace will not match.

In Rails, read request.raw_post before touching params. In Express, mount express.raw({ type: 'application/json' }) on the webhook route instead of the global express.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

GuaranteeAt least once. An event can arrive more than once.
RetriesUp to 8 attempts, spread over roughly 90 minutes.
Retried onConnection failures, timeouts, and your 5xx.
Not retriedYour 4xx. The event is abandoned on the first one.
TimeoutsConnect 5s, read 10s.
Rate5 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

  1. Read the raw body.
  2. Verify X-Gifq-Signature and reject on mismatch.
  3. Build an idempotency key from the body and skip keys you have already handled.
  4. Route on event and payout_type.
  5. 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
end

Enforce 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.