Skip to content

Idempotent webhook handler

One event arriving twice, events out of order, an old event arriving late — all of this is normal (Delivery). A handler written to the pattern on this page does the right thing exactly once in every one of those cases: no goods handed over twice, no order closed twice.

  • Time: 30 minutes.
  • You need: signature verification (Signatures), a database, a background queue (or at least a separate goroutine / worker), and an API key with the payments:read scope (for GET /payment-intents/{id}).

1. The rules

  1. Verify — the signature, over the raw body. If it is wrong, answer 400 and do nothing.
  2. Deduplicate — write the event's id (evt_…) to a table with a unique key. If it is already there, answer 2xx and stop.
  3. Queue it and answer 2xx fast — within 10 seconds. Slow work (handing over goods, e-mail, stock) does not belong in the handler.
  4. Re-read the object — the worker reads the intent's current state with GET /payment-intents/{id}.
  5. Decide on the current state, not on the event's type or order: succeeded → fulfil; canceled/expired → close the order. An old event (say a late claimed) never moves the current state back.
  6. Make your own action idempotent — record "order 7 fulfilled" on the order's row and check it before fulfilling (deduplicating events protects one event; two different events must not fulfil one order twice either).
  7. Answer 2xx to a type you do not know — a minor version may add events.
  8. Never hand over real goods on a livemode: false event.

2. The dedupe table

CREATE TABLE gerege_webhook_events (
    event_id     TEXT PRIMARY KEY,          -- evt_… (Gerege-Event-Id)
    type         TEXT NOT NULL,
    object_id    TEXT,                      -- pi_…, re_…, st_…
    livemode     BOOLEAN NOT NULL,
    body         JSONB NOT NULL,            -- the body exactly as received (for checks and replays)
    received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    processed_at TIMESTAMPTZ
);

-- The handler: 1 row when new, 0 rows for a duplicate.
INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING;

The worker sets processed_at when it is done; rows left without it (a worker that crashed) are the ones to process again.

3. Examples

import express from "express";
import { GeregeMiniApp, GeregeWebhookError } from "@gerege/miniapp-node";

const gerege = new GeregeMiniApp({
  clientId: process.env.GEREGE_CLIENT_ID!,
  clientSecret: process.env.GEREGE_CLIENT_SECRET!,
});
const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(",");
const app = express();

// BEFORE express.json(), raw body on this route only
app.post("/webhooks/gerege", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = gerege.webhooks.verify(req.body, gerege.webhooks.signatureFromHeaders(req.headers), secrets);
  } catch (err) {
    if (err instanceof GeregeWebhookError) return res.sendStatus(400);
    throw err;
  }
  const objectId = (event.data.object as { id?: string }).id ?? null;
  const inserted = await db.query(
    `INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
     VALUES ($1, $2, $3, $4, $5) ON CONFLICT (event_id) DO NOTHING`,
    [event.id, event.type, objectId, event.livemode, req.body.toString("utf8")],
  );
  if (inserted.rowCount === 1) await queue.add("gerege-event", { eventId: event.id });
  res.sendStatus(204); // a duplicate gets a 2xx too
});

// The worker: decides on the current state
async function handle({ eventId }: { eventId: string }) {
  const { type, object_id: id } = await loadEvent(eventId);
  if (!type.startsWith("payment_intent.") || !id) return markProcessed(eventId);
  const pi = await gerege.paymentIntents.retrieve(id); // client credentials
  const order = await findOrderByReference(pi.reference);
  if (!order || pi.livemode !== order.livemode) return markProcessed(eventId); // test ≠ live
  switch (pi.status) {
    case "succeeded":
      await fulfilOnce(order, pi.id); // mark the order fulfilled, then fulfil
      break;
    case "requires_capture":
      // manual: capture if the goods came out, cancel if not
      break;
    case "canceled":
    case "expired":
      await closeOrder(order);
      break;
  }
  await markProcessed(eventId);
}
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"

var (
    secrets = strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",")
    gerege  = miniapp.NewClient(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"))
)

func webhook(w http.ResponseWriter, r *http.Request) {
    ev, body, err := miniapp.VerifyRequest(r, secrets, time.Now())
    if err != nil {
        http.Error(w, "invalid webhook", http.StatusBadRequest)
        return
    }
    var obj struct {
        ID string `json:"id"`
    }
    _ = json.Unmarshal(ev.Data.Object, &obj)
    tag, err := db.Exec(r.Context(), `INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
        VALUES ($1, $2, NULLIF($3, ''), $4, $5) ON CONFLICT (event_id) DO NOTHING`,
        ev.ID, ev.Type, obj.ID, ev.Livemode, string(body))
    if err != nil {
        http.Error(w, "try again", http.StatusInternalServerError) // Gerege will retry
        return
    }
    if tag.RowsAffected() == 1 {
        jobs <- ev.ID
    }
    w.WriteHeader(http.StatusNoContent)
}

func handle(ctx context.Context, eventID string) error {
    e, err := loadEvent(ctx, eventID)
    if err != nil || !strings.HasPrefix(e.Type, "payment_intent.") {
        return err
    }
    pi, err := gerege.PaymentIntents.Get(ctx, e.ObjectID)
    if err != nil {
        return err // try again later
    }
    switch pi.Status {
    case "succeeded":
        return fulfilOnce(ctx, pi)
    case "canceled", "expired":
        return closeOrder(ctx, pi)
    }
    return nil
}

4. Common mistakes

Mistake What happens Instead
Verifying the body after express.json() or another framework parsed it Signatures never match, every event 400 The raw bytes
Handing over goods or sending e-mail inside the handler Over 10 s → timeout → retry → twice A queue, a fast 2xx
Trusting only the event's type A late event brings an old state back The current state via GET
No deduplication Fulfilled again on every retry A unique event_id
400/500 for an unknown type 8 attempts, wasted load 2xx and ignore it
Not checking livemode Real goods for a test payment livemode, and the intent's livemode
Trusting return_url?pi=… (redirect) Anybody can type a URL The webhook or GET

To catch up on missed events: the Events API — the same body as the webhook, through the same handler (deduplicated on the event id, so what already arrived is skipped). If two endpoints point at one handler, one event arrives twice — again handled once, by id.