Mantissa Hooksnode is a Mantissa product A Mantissa product mantissatech.xyz

Why your payment webhook arrived twice (Paystack, Flutterwave, Stripe)

Author:
hooksnode
Published:
Updated:
  • paystack
  • flutterwave
  • stripe

The customer was charged once, and your handler credited them twice. Paystack, Flutterwave and Stripe deliver at least once, so the same event can arrive again after a slow response, a failed response, or a person clicking Retry. Stop the second credit in the handler: store one stable ID, and do the work only when that insert is new.

The short answer

Every one of these providers delivers at least once. A retry after a slow or non-200 response, a manual dashboard retry, and a resend all deliver the same event again. Your handler must be safe to run twice.

Answer 200 when you have already done the work, and stop. Do not credit the customer again.

Why the same event arrives twice

  • Your endpoint answered late, or not with a 200. The provider treats that as a failure and sends the event again, even when the handler already credited the wallet. Doing the work and then timing out is the usual double credit.
  • Someone clicked Retry, or called resend. Paystack's webhook docs say resending is not idempotent: if you already processed the event, a resend delivers it again. The Retry button on the new dashboard sends the event again to the webhook URL currently saved for that mode, which can differ from the URL on the first attempt. The dashboard retry article and the webhook docs both tell you to ignore an event you have already processed. The delivery-record checks are in Why your Paystack webhook is not working.
  • Flutterwave retries when retries are enabled on the account. If Enable Webhook retries is on (Settings, Webhooks), Flutterwave retries 3 times, 30 minutes apart. If it is off, a failed delivery is not retried. See Why your Flutterwave webhook is not arriving.
  • Stripe documents that an endpoint may receive the same event more than once. A failed delivery is one of those cases, and Stripe signs each of its own retries with a new timestamp. See Stripe webhook signature verification failed.

The ID to dedupe on

Use an ID the provider keeps stable across those deliveries. A header that changes on each attempt will not match the first one. The Hooksnode callback id will not either: Replay stores a new event.

Provider Event ID
Paystack charge.success data.reference
Flutterwave payment webhook data.tx_ref
Stripe any event event id

If one Paystack reference can carry more than one event, store the event name with data.reference, for example charge.success:<reference>, and use that same pair in every service.

Store the ID in the same transaction as the work

A check followed by a separate insert is a race. Two deliveries can both see "not stored yet" and both credit the customer. Insert the ID with a unique constraint in the same database transaction as the work. This is the statement from the Idempotency doc:

INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- No row back: you already handled it. Answer 200 and stop.

event_id needs a unique constraint. A primary key is that constraint:

CREATE TABLE processed_events (
  event_id   text PRIMARY KEY,
  handled_at timestamptz NOT NULL DEFAULT now()
);

No row back means you already handled it. Answer 200 and stop. If a unique-key error becomes a 500, the provider retries a payment you already applied.

Node.js

async function handleOnce(db, eventId, work) {
  await db.tx(async (t) => {
    const row = await t.oneOrNone(
      `INSERT INTO processed_events (event_id) VALUES ($1)
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [eventId]
    );
    if (!row) return; // Already handled. Still answer 200.

    await work(t);
  });
}

// Paystack charge.success. Flutterwave: data.tx_ref. Stripe: event.id.
await handleOnce(db, event.data.reference, (t) =>
  t.none(
    `INSERT INTO ledger_entries (reference, amount, currency)
     VALUES ($1, $2, $3)`,
    [event.data.reference, event.data.amount, event.data.currency]
  )
);

Python

def handle_once(conn, event_id, work):
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO processed_events (event_id) VALUES (%s)
                ON CONFLICT (event_id) DO NOTHING
                RETURNING event_id
                """,
                (event_id,),
            )
            if cur.fetchone() is None:
                return
            work(cur)

psycopg commits when the with conn block finishes, and rolls back if work raises. No row means the ID was already stored, so work does not run. Answer 200 either way.

Pass Paystack data.reference, Flutterwave data.tx_ref, or the Stripe event id.

Answer fast, then do the work

A slow handler is the main reason a provider retries. Check the request, store the ID in the same transaction as the credit, answer 200, and leave email, receipts, and other slow calls until after that response.

How retries behave once the provider has decided the attempt failed is in Webhook retry strategies.

What Hooksnode does, and what it does not

Hooksnode delivers to destinations at least once, never exactly once. A service can still see the same payment twice. Keep the unique insert in each destination. One URL in front of several services is Send one Paystack webhook to several services, Send one Flutterwave webhook to several services, or Send one payment webhook to many services.

Signed sources

With inbound signing on (the Paystack, Flutterwave and Stripe presets), Hooksnode ignores X-Idempotency-Key. It drops an exact resend of a signed request (the same body and signature) within 10 minutes, and answers with the same duplicate body:

{"status":"duplicate","message":"already accepted"}

That is the duplicate response, not a second copy of the first accepted body (callback_id and enqueued). The first delivery is still delivered to your destinations. The dropped resend is not stored.

A provider retry or a dashboard Retry that arrives later than 10 minutes, or with a new signature, is a new event and is delivered again. Destinations still need to dedupe.

Stripe signs each of its own retries with a new timestamp, so a Stripe retry is a new signature, including inside the 10 minutes, and is delivered again. Paystack also re-signs retried and resent events with a new timestamp on Paystack-Signature, but Hooksnode's exact-resend check uses the body plus X-Paystack-Signature, which has no timestamp. A same-body Paystack retry inside 10 minutes can still be dropped. After 10 minutes it is a new event and is delivered again. Flutterwave's retries, when they are enabled, are 30 minutes apart, which is later than that window.

Your own senders, with signing off

When inbound signing is off, send X-Idempotency-Key. Hooksnode stores the first event with that key. The key is stored on the project. When the event has a source, the stored key is prefixed with that source, so the same header on another source is a new event. A repeated key on the same source stores nothing and answers 200 with:

{"status":"duplicate","message":"already accepted"}

Treat status of duplicate as success. The header is ignored when inbound signing is on, so a Paystack, Flutterwave or Stripe preset source will not dedupe on it.

Retry and Replay

Retry never sends the event again to a destination that already got it. It only goes to destinations whose delivery is dead. Use retry after an outage.

Replay makes a new event and sends it to every active destination that takes that source and event type. Replay is a deliberate duplicate. Each service runs the handler again, and the unique insert above is what keeps the customer from being credited twice. Use Replay to test a destination with an old event, not as a way to apply the payment again.

See Retry and replay and Idempotency.

If a destination also checks Paystack's signature on the raw body, that check is in Verify Paystack webhook signatures (Paystack-Signature and x-paystack-signature).

Checklist

  • Dedupe on the provider ID: Paystack data.reference, Flutterwave data.tx_ref, Stripe event id
  • Insert that ID with a unique constraint in the same database transaction as the credit or ledger write
  • When the insert returns no row, answer 200 and do no further work
  • Answer the provider quickly, then do slow work after the response
  • Treat Hooksnode Replay as a second delivery of the same payment

Try the free project

The project Free plan is $0 and includes 100,000 credits a month, with a hard cap: when credits run out, new events get 402 instead of a bill. Checked against the public plans API on 2026-11-12. See pricing.

Start free.

FAQ

Why can the same payment webhook arrive more than once?

Providers deliver at least once. A slow or non-200 response, a Paystack dashboard Retry or resend, Flutterwave retries when they are enabled, and a Stripe retry can all send the same event again.

Which ID should I dedupe on?

Paystack charge.success uses data.reference. Flutterwave uses data.tx_ref. Stripe uses the event id. Insert that ID with a unique constraint in the same database transaction as the work.

Does Hooksnode deliver a payment webhook exactly once?

No. Delivery is at least once, never exactly once. With inbound signing on, Hooksnode drops an exact resend of the same body and signature within 10 minutes. A later retry, or one with a new signature, is delivered again.

What does Replay do?

Replay makes a new event and sends it to every active destination that takes that source and event type. That is a deliberate duplicate. Retry never sends the event again to a destination that already got it.

Further reading