Idempotent webhook handler¶
Нэг event хоёр удаа ирэх, дараалал холилдох, хуучин event хожуу ирэх нь хэвийн (Хүргэлт). Энэ хуудасны загвараар бичсэн handler эдгээр бүгдэд нэг л удаа, зөв ажилладаг: давхар бараа олгохгүй, захиалгыг хоёр удаа хаахгүй.
- Хугацаа: 30 минут.
- Хэрэгтэй: гарын үсэг шалгалт (Гарын үсэг), өгөгдлийн сан, background queue (эсвэл ядаж тусдаа
goroutine / worker),
payments:readscope-той API түлхүүр (GET /payment-intents/{id}-д).
1. Зарчим¶
- Шалга — түүхий body дээр гарын үсэг. Буруу бол
400, юу ч бүү хий. - Давхардлыг арилга — event-ийн
id-г (evt_…) unique түлхүүртэй хүснэгтэд бич. Аль хэдийн байвал2xxбуцаагаад зогс. - Queue-д хий, 2xx-ыг хурдан буцаа — 10 секундээс өмнө. Удаан ажил (бараа олгох, имэйл, нөөц) handler дотор биш.
- Объектыг дахин унш — worker нь
GET /payment-intents/{id}-ээр intent-ийн одоогийн төлөвийг уншина. - Одоогийн төлөвөөр шийд, event-ийн төрөл ба дарааллаар биш:
succeededбол олго,canceled/expiredбол захиалгыг хаа. Хуучин event (жишээ нь хожуу ирсэнclaimed) одоогийн төлөвийг буцааж өөрчлөхгүй. - Өөрийн үйлдлийг idempotent болго — «захиалга 7-г олгосон» гэдгийг захиалгын мөрөнд бич, олгохоос өмнө шалга (event-ийн dedupe нь нэг event-ийг хамгаална; хоёр өөр event нэг захиалгыг хоёр удаа олгох ёсгүй).
- Мэдэхгүй
type-д2xx— шинэ event minor хувилбарт нэмэгдэж болно. livemode: falseevent-ээр бодит бараа олгохгүй.
2. Dedupe хүснэгт¶
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, -- ирсэн яг тэр body (шалгалт, дахин тоглуулахад)
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
-- Handler: шинэ бол 1 мөр, давхар бол 0 мөр.
INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING;
Worker ажлаа дуусгаад processed_at-ийг бичнэ; дутуу үлдсэн мөрийг (worker унасан) дахин боловсруулахад ашиглана.
3. Жишээ¶
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();
// express.json()-оос ӨМНӨ, зөвхөн энэ замд түүхий body
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); // давхар ч 2xx
});
// Worker: одоогийн төлөвөөр шийднэ
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 credential
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); // захиалгын мөрөнд «олгосон» гэж бичээд л олгоно
break;
case "requires_capture":
// manual: бараа гарвал capture, үгүй бол cancel
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 дахин илгээнэ
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 // дараа дахин
}
switch pi.Status {
case "succeeded":
return fulfilOnce(ctx, pi)
case "canceled", "expired":
return closeOrder(ctx, pi)
}
return nil
}
4. Түгээмэл алдаа¶
| Алдаа | Юу болох | Зөв нь |
|---|---|---|
express.json() эсвэл framework-ийн parse хийсэн body дээр шалгах |
Гарын үсэг таарахгүй, бүх event 400 |
Түүхий byte |
| Handler дотор бараа олгох, имэйл илгээх | 10 с хэтэрч timeout → retry → давхар | Queue, хурдан 2xx |
Event-ийн type-д л итгэх |
Хожуу ирсэн event хуучин төлөвийг сэргээнэ | GET-ээр одоогийн төлөв |
| Dedupe-гүй | Retry бүрт дахин олгоно | event_id unique |
Мэдэхгүй type-д 400/500 |
8 оролдлого, дэмий ачаалал | 2xx, үл тоомсорло |
livemode шалгахгүй |
Test төлбөрөөр бодит бараа | livemode, intent-ийн livemode |
return_url?pi=…-д итгэх (redirect) |
Хэн ч URL бичиж чадна | Webhook эсвэл GET |
Алдсан event-ийг нөхөх: Events API — webhook-тэй ижил body, ижил handler-аар (event id-аар dedupe
хийсэн тул давхар ирсэн нь алгасагдана). Хоёр endpoint нэг handler руу заасан бол нэг event хоёр удаа ирнэ — мөн
id-аар нэг удаа л боловсруулагдана.