FAQ / Webhooks
Webhooks: Questions and Answers
Receiving, verifying and de-duplicating webhooks, and managing your subscription and delivery log. 32 questions, grouped by the documentation page each one is about. Where a response or webhook carries a figure, read it there rather than recomputing it.
Webhooks
Which header carries the signature, and what format does it use?
X-Invo-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>. During a secret rotation it carries two v1= values.
What exact string is signed, and how is the digest encoded?
The signed string is <timestamp>.<raw_body>: the t value, a period, then the exact body bytes. It is signed with HMAC-SHA256 keyed with your signing secret and hex-encoded. Compare in constant time.
What is the replay tolerance window, and does it also reject future timestamps?
Reject timestamps more than 5 minutes (300 seconds) from your server's clock. The official SDK verifiers compare the absolute difference, so a timestamp more than 5 minutes in the future is rejected too.
Why must I verify against raw bytes, and what happens with gzip?
The signature covers the exact bytes INVO sent, and re-serialising a parsed object can change spacing or key order and break the match. If you enabled compression, INVO signs the uncompressed JSON and then gzips it, so decompress first and verify the decompressed bytes.
How do I handle multiple v1= values during secret rotation?
Accept the request if any v1= value matches a signature computed with a secret you hold. For 7 days after a rotation, every delivery is signed with both the new and the previous secret. Deploy the new one and let the old one expire.
Should I dedupe on X-Invo-Idempotency-Key or X-Invo-Event-Id, and why?
Dedupe on X-Invo-Idempotency-Key. It stays the same for a logical event across retries and manual replays. X-Invo-Event-Id stays the same on retries but is new on a replay, so deduping on it would process a replay twice.
What is the retry schedule, and how many attempts happen before an event is dead-lettered?
Any response other than 2xx is retried after about 30 s, 2 min, 10 min, 1 h and 6 h, each with a little jitter. By default that is 6 delivery attempts in all (the first one included), after which the delivery is dead and can be replayed from the delivery log. You can set max_attempts from 1 to 12 per subscription; above 6, further retries come a day apart.
How long does my endpoint have to respond?
10 seconds by default. You can set timeout_seconds from 1 to 60 through the retry policy. Return a 2xx quickly and move slow work to a queue.
What constraints apply to target_url?
It must be HTTPS, at most 2,048 characters, and resolve to a public internet address. Redirects are not followed, so point it at the final URL.
Why is subscribing to ["*"] recommended, and why must a subset include transfer.claim_pending?
With ["*"] you get every current and future event type and can filter on your side, which means you never miss a new event. If you choose a subset, include transfer.claim_pending: it is the only event that tells you an inbound transfer or send is waiting for your player to collect. INVO does not enforce this, so leaving it out silently drops those notices.
On purchase.completed, when are partner_fee_usd and international_card_fee_usd null versus absent?
They are present on an INVO card purchase of coins. They are null only when the fee couldn't be booked at completion: the purchase still succeeded and INVO books the fee shortly after. They are absent where no INVO card fee applies, such as platform store and game storefront purchases. Type both as nullable strings.
How does a platform store chargeback on purchase.disputed differ from a card dispute?
A card dispute fires when it opens and again when it closes, with dispute_status (for example won or lost). A platform store chargeback arrives once, already "lost", with payment_intent_id, charge_id and dispute_id set to null, plus rail: "steam" and steam_transid. A platform store chargeback never carries guarantee_covered or dispute fee fields. A card dispute's open event carries order_id, transaction_id, payment_intent_id, charge_id, dispute_id, reason and usd_amount; a closed coin-purchase dispute adds currency_debited, new_balance and spent_coins_charged_usd. For a card dispute opened from 2026-10-21, on either tier, the closed dispute also carries dispute_fee_usd and dispute_fee_charged_to_partner (the card network's dispute fee, passed through at cost). There is no purchase.dispute_lost event.
On same-title moves, how do the outbound and inbound copies of transfer.claim_pending differ, and since when are inbound copies sent?
The outbound copy (direction: "outbound") goes to the sending title and the inbound copy (direction: "inbound") goes to the destination title. Only the inbound copy carries to_phone and to_identity_id. Since 2026-10-09, when both players are in your title you receive both copies, each with its own event_id and idempotency key. Dedupe per copy and act on direction.
How should to_phone and to_identity_id be used together to match a recipient?
Match on to_phone first, because it is always the reliable key. If to_identity_id is also set, exactly one of your players owns the number. If it is null while to_phone is set, the number belongs to more than one of your accounts, and a real transfer is still waiting. If both are null, the recipient has no account on your title yet and will onboard with the claim code.
How do I attach correlation data through metadata, and which keys does platform store reserve?
Pass a metadata object, up to 8 KB, on the purchase call: purchase-currency, a hosted checkout session or steam/init-purchase. It comes back at data.metadata on purchase.completed. Your purchase_reference is not echoed. On the platform store rail, avoid steam_wallet_currency, steam_country, steam_account_status and vat_rate_pct, which INVO adds. Keys starting with _invo_ are reserved.
Why should I not grant currency on a purchase.completed whose metadata.source is subscription_renewal?
That event reports the coins a paid subscription renewal minted, and the renewal spends those same coins. Granting currency on it would double-credit. Use subscription.renewed as the entitlement signal.
What does purchase.refunded debit if the player already spent the coins?
Only the coins the player still holds from that purchase, never more than their available balance, so the balance never goes below zero. currency_debited and new_balance tell you what happened. Spent coins are not taken back.
What should my handler do with an unrecognised event type?
Return a 2xx, log the full raw body with its idempotency key, and move on. New event types and fields are added without notice. Logging the raw body lets you process the event later, because after a 2xx INVO will not resend it.
Webhook Management API
Read the Webhook Management API page
How do I create or update a subscription with PUT /api/dev/webhooks/games/{game_id}, and how do I tell a 201 from a 200?
Send { "target_url": "...", "subscribed_events": ["*"] } with your X-Game-Secret-Key. subscribed_events must be ["*"] or a list of supported types; unknown types are 400. A 201 with created: true means the subscription was created and includes the one-time signing_secret. A 200 means an existing subscription was updated, with no secret.
What happens if I lose the signing_secret?
It can't be retrieved again. Call POST /api/dev/webhooks/games/{game_id}/rotate-secret to get a new one. If you still have the old secret, it keeps verifying deliveries for 7 days.
What does DELETE do, and does PUT re-enable a disabled subscription?
DELETE disables the subscription (is_active: false) but keeps it and its delivery history. It returns 404 if no subscription exists. A later PUT re-enables it.
How does rotate-secret work, and what do previous_secret_expires_at and secret_version tell me?
rotate-secret returns a new signing_secret once and keeps the previous secret valid for 7 days. previous_secret_expires_at is when the old secret stops working, and it is null outside a grace window. secret_version goes up by one on each rotation and matches the X-Invo-Secret-Version header on deliveries.
What does the test endpoint send, and how do I confirm the result?
POST /api/dev/webhooks/games/{game_id}/test queues a signed webhook.test event and returns 201 with delivery_id, event_id and a note. It returns 404 if there is no active subscription. Check the outcome at GET /api/dev/webhooks/deliveries/{delivery_id}, which shows the status, response code and your response body.
How do I filter delivery history by status, event_type, page and per_page?
Call GET /api/dev/webhooks/games/{game_id}/deliveries with these query parameters:
status: one ofall,pending,in_progress,succeeded,failedordead(defaultall).event_type: optional filter by event type.page: default 1.per_page: default 25, maximum 100.
The response carries items and pagination (page, per_page, total, total_pages).
Which delivery statuses exist, and which are terminal?
A delivery moves from pending to in_progress, then ends as succeeded or dead. Those two are terminal. dead means the retries ran out. failed is accepted as a filter value but is not currently assigned, since failures go back to pending until they succeed or die.
When can I replay a delivery, and why does replay return 409?
You can replay a delivery that ended dead with POST /api/dev/webhooks/deliveries/{delivery_id}/replay. Other statuses get 400. A 409 means one of two things:
- The subscription is disabled. Re-enable it with PUT first.
- The event has reached its replay cap. The response includes
redrive_count.
How does a replay keep the same idempotency key with a new event_id, and what is the redrive cap?
A replay creates a new delivery with a new event_id and copies the original idempotency_key, so your dedupe on X-Invo-Idempotency-Key still catches it. The response returns replay_delivery_id, replay_event_id and original_delivery_id. Each logical event can be replayed up to 20 times.
How are health_state and success_rate_24h derived?
success_rate_24h is succeeded deliveries divided by attempted deliveries over the last 24 hours, or null with no attempts. health_state is set as follows:
never: no attempts in 7 days.failing: the last 3 attempts all failed, or more than half of recent attempts in the last hour failed.healthy: the last 3 attempts succeeded.
What do timeout_seconds, max_attempts and max_deliveries_per_minute do?
Set them with PATCH /api/dev/webhooks/games/{game_id}/retry-policy, and send null to clear an override:
timeout_seconds(1 to 60): how long INVO waits for your 2xx.max_attempts(1 to 12): attempts before a delivery goesdead.max_deliveries_per_minute(1 to 600): throttles deliveries to protect your receiver.
Does a throttled delivery consume a retry attempt?
No. A delivery held back by max_deliveries_per_minute stays pending and is tried again later without using an attempt.
When does compress_payloads apply, and how do I verify signatures with gzip?
With compress_payloads on, bodies of 1 KB or more are sent gzipped with Content-Encoding: gzip. Smaller bodies are sent uncompressed. INVO signs the uncompressed JSON, so decompress first and verify the signature against the decompressed bytes. Turn it on only if your receiver decodes gzip, because a non-2xx reply (including 415) is retried like any other failure.
How do I list supported event types?
Call GET /api/dev/webhooks/supported-events. The list covers:
purchase.completed,.failed,.refunded,.disputedand.fraud_warning.transfer.sent,.received,.claim_pending,.claim_expiredand.refunded.item.purchased,platform_commerce.purchasedandplatform_commerce.refunded.payout.status_changedandplayer.phone_changed.- The
subscription.*lifecycle events.
purchase.dispute_lost and balance.updated are not emitted.
Back to the FAQ hub. Something not answered here? Email partners@invo.network.