Four ways one payment turns into two plays
A viewer pays for a TTS read, the voice fires, and then it fires again three seconds later. Nobody paid twice. Usually the duplicate comes from one of four places, and each needs a different fix.
The first is the platform itself. Twitch's webhook docs say it sends a notification at least once, and that if it is unsure whether you received one, it will resend. Kick's webhook security page labels its message ID an idempotent key but does not spell out a retry policy, so plan for repeats there too.
The second is your own receiver being slow. If your endpoint answers late, the sender may decide the delivery failed and try again, even though you already queued the alert (Twitch documents the timeout; Kick's retry timing is not spelled out). The third is two copies of your bot running: a laptop instance you forgot about plus the cloud one, or a test script still subscribed from last week. The fourth is two browser source instances, such as the same alert page loaded in OBS and also open in a browser tab.
- Platform resend: same message, same ID, arrives twice.
- Slow acknowledgement: your first reply came too late, so a retry follows.
- Two bot instances: each holds its own connection and each reacts.
- Two overlay instances: one event, two pages playing it.
What the platform ID actually identifies
On Twitch webhooks, the Twitch-Eventsub-Message-Id header is described as an ID that uniquely identifies the message, opaque and not required to follow any format. Twitch also lists a Twitch-Eventsub-Message-Retry header; read its current wording before building on it, and treat it as a log hint, not the dedupe rule.
On Twitch WebSocket connections, the equivalent is the message_id field in the metadata of each message, which is what the docs point to for deduplication. On Kick, the Kick-Event-Message-Id header (a ULID) is labeled as the idempotent key, next to a subscription ID, a type, a version, and an RFC3339 timestamp.
Here is the catch that matters for a paid queue. A message ID identifies one delivery envelope from the platform. It does not promise that two different envelopes cannot describe the same real-world thing. Two bot instances that each own a separate connection are a good example: do not assume their IDs line up. Test with a throwaway channel.
A second key for the money itself
Because of that catch, borrow an idea from payments. Stripe's webhook docs say to log the event IDs you have processed and skip ones you have seen, and they add a warning: in some cases two separate event objects are generated, and to catch those you use the ID of the object in the data plus the event type.
Translate that to a stream. The envelope ID is your first key. The second key is whatever identifies the thing being paid for: the cheer or subscription it describes, the chat message ID attached to a paid read, the tip's own transaction reference from your tip page. If two envelopes with different message IDs carry the same underlying reference and type, that is one paid moment, not two.
Example: a gifted sub plays its alert, then a reconnect produces a second envelope about the same sub. Only the second key stops it. Without it, the ledger shows two plays against one sale.
The ledger, column by column
A paid-event ledger is a plain append-only table. One row per delivery received, not one row per payment, because the whole point is to see the repeats. Never edit rows in place; a correction is a new row that points to the old one.
- Platform: Twitch webhook, Twitch WebSocket, Kick webhook, or a tip page.
- Message ID: the envelope ID exactly as received, stored as text.
- Business key: the second key for the underlying thing paid for, when the platform gives one.
- Event type and version: so a version change shows up in the history.
- Received time: when your side saw it, in one timezone, plus the platform's own timestamp if provided.
- Amount and currency as reported: copy what the source says, do not convert or round it.
- Moderator decision: approved, rejected, held, or auto-handled, with a short reason and who made the call.
- Played-at: when audio or visuals actually ran, blank if they did not.
- Outcome: played, suppressed as duplicate, rejected, expired, failed to play, or replayed by hand.
Duplicate, late, or out of order: three different rulings
A duplicate is the same message ID, or the same business key and type, seen again. Write the row, mark it suppressed duplicate, and play nothing. If the first copy is still in the approval queue, the second must not create another queue item.
A late event is a genuine new message that arrives well after the moment it describes. Twitch's docs tell receivers to check that the message timestamp is not older than ten minutes, which is a replay guard for signed requests. Stripe's libraries use a five minute tolerance by default for the same purpose. Decide your own window in advance and log the rejection. Past it, send the item to a moderator as held instead of playing it live; a read landing twenty minutes late in a different segment can do real harm.
Out of order is the one people forget. Stripe says plainly that it does not guarantee events arrive in the order they were generated, and says not to depend on created timestamps to decide order. Assume the same about any alert source. A cancel-like event can show up before the thing it cancels. Keep a state per business key instead of acting on the arrival order, and let the latest known state decide what the mod sees.
The silent-quiet failure: revoked and unsubscribed
The scariest bug is not a double play. It is nothing playing, with no error anywhere, and everyone assuming chat is slow tonight.
Twitch tells you when it cancels a webhook subscription: a revocation message arrives with a status. The documented ones are user_removed, authorization_revoked (the user revoked authorization or changed their password), notification_failures_exceeded (your callback failed to respond quickly enough too many times), and version_removed. Twitch says to answer notifications within a few seconds, and that repeated slow answers lead to that third status. That means a receiver that is slow under load can unsubscribe itself from its own revenue.
Kick describes a quieter version. If an app's webhook continually fails to process an event for over a day, Kick automatically unsubscribes the app from that event, and the app has to resubscribe. The wording does not promise a notification, so assume none.
So put these into the ledger as first-class rows: revocation received, subscription lost, resubscribed. And add a heartbeat check that does not depend on viewers. If a channel with a normal flow of events has had zero rows of a given type for an unusually long stretch, a human gets pinged. A pre-stream check that lists current subscriptions is cheap insurance.
Reconnect gaps and honest backfill
WebSocket connections add their own wrinkles. Twitch documents a keepalive timeout you can set between 10 and 600 seconds, a ten second window after the Welcome message to create your subscription (miss it and the connection closes with code 4003), and a reconnect notice sent 30 seconds before the old connection closes. Miss the grace time for connecting to the new URL and you get 4004. If you run more than one bot, note the listed limits: three connections, 300 enabled subscriptions per connection, max total cost 10 per user token.
The part to quote to your team is this one: Twitch states there is no replay of events lost while you establish a new connection and resubscribe. The gap is real. If your bot dropped for forty seconds and a sub landed in the middle, you will not get it later from the stream of events.
Backfill is where honesty matters. After a gap, a tool can sometimes query a platform's API or a payment page for what it missed and add rows. Mark those rows as backfilled, with the time they were added, and never replay them to the overlay as if they happened live. A visible gap can be explained to a viewer; a papered-over one resurfaces at payout time.
Reconciling against payouts once a day
Once a day, after the stream, compare the ledger to whatever the money side says. Describe it generically: pick the platform's payout or earnings report, or your tip provider's transaction export, and line it up by business key and day. Streamable Bots lists custom tip links, on-stream alerts, and a creator payout workflow on its site, and whichever tool you use, the payout side is the one your ledger totals have to tie back to.
Three buckets come out of that comparison. Paid and played: the normal case. Paid but not played: look in the ledger for a rejection, an expired hold, a failed play, or a missing delivery, and make sure a human decided on purpose what to do for the viewer. Played but not paid: usually a duplicate that slipped through, a test event, or a refund that arrived after the alert.
Currency conversion, fees, and day boundaries can all move a total, which is why the ledger stores amounts exactly as the source reported them.
Replay tests, live mod reading, and what to keep
Before a big stream, test the dedupe on purpose. Send the same signed test delivery twice, and a second one with a different message ID but the same business key. Send one with a timestamp outside your window. Disconnect the bot mid-test and bring it back. Open the overlay in two places. The expected result is one play, three ledger rows with the right outcomes, and a visible hold for the late one. Keep a small folder of recorded sample bodies for this.
Moderators should see a trimmed version of the ledger live: the last twenty rows, filtered to paid types, with outcome colors that read at a glance. Their questions are simple: did that already play, is this a repeat of something queued, has anything arrived lately?
Last, keep less than you can. The ledger holds viewer names, message text, and amounts, so give access only to the people who review payouts, and set a retention period you can defend. Never store signing secrets or the raw signature headers in it, and keep message bodies only as long as disputes realistically last. Verify signatures before anything is written: Twitch signs the message ID, timestamp, and raw body with HMAC-SHA256, and Kick signs the same three parts with RSA, checked against a public key fetched from Kick instead of one pasted into your code, since the key can rotate.
Quick answers
Which header should I dedupe on for Twitch webhooks?
Use Twitch-Eventsub-Message-Id as the first key and store it as plain text, since Twitch describes it as opaque. If you run more than one connection or instance, add a second key for the thing being paid for, because matching envelope IDs across separate connections is not something to assume.
Is Kick-Event-Message-Id enough on its own?
Kick labels it the idempotent key, so it is the right first check. Pair it with a business key from the event body or your tip page so that two different deliveries about one payment are still caught.
How would I know my subscription got removed?
On Twitch, a revocation message carries a status such as notification_failures_exceeded or authorization_revoked. On Kick, auto-unsubscribe after a day of continual failures may arrive with no obvious warning. Log revocations, and alert on an unusually long silence in event types that normally flow.
Can I recover events missed during a reconnect?
Twitch says it does not replay events lost while you reconnect and resubscribe. You can sometimes backfill from another source, but tag those rows as backfilled and do not play them as live alerts.
How long should a ledger be kept?
Long enough to cover payout checks and viewer disputes, and no longer. Pick a number with whoever handles payouts, restrict access, and keep secrets and raw signature headers out of it.
Resources
- Twitch Developers: handling EventSub webhooks (at-least-once delivery, message ID, signature, response time, revocation statuses)
- Twitch Developers: handling EventSub WebSocket events (keepalive range, close codes, reconnect, limits, no replay of lost events)
- Twitch Developers: EventSub overview (message_id tracking and the ten minute timestamp check)
- Kick Docs: webhook security (message ID as idempotent key, RSA signature, public key rotation, auto-unsubscribe)
- Stripe Docs: receiving webhook events (duplicate handling, event ordering, replay tolerance, async processing)
