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:readscope (forGET /payment-intents/{id}).
1. The rules¶
- Verify — the signature, over the raw body. If it is wrong, answer
400and do nothing. - Deduplicate — write the event's
id(evt_…) to a table with a unique key. If it is already there, answer2xxand stop. - Queue it and answer 2xx fast — within 10 seconds. Slow work (handing over goods, e-mail, stock) does not belong in the handler.
- Re-read the object — the worker reads the intent's current state with
GET /payment-intents/{id}. - 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 lateclaimed) never moves the current state back. - 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).
- Answer
2xxto atypeyou do not know — a minor version may add events. - Never hand over real goods on a
livemode: falseevent.
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.