Webhooks — overview¶
Gerege tells your server about events — a payment succeeding, a hold being placed, a person scanning a QR, a refund
finishing — with a POST request. After this page you will have added a webhook endpoint, stored its secret, and
know where the rest of this section fits.
- Time: 10–15 minutes (adding an endpoint + a handler that verifies signatures).
- You need: an app registered in the developer console (a mini app or a merchant), an https address reachable from the public internet, and somewhere to keep the secret (an environment variable, a secret manager — not the repository).
Required
An app with the payments scope, and every merchant, must have an active live webhook endpoint — endpoint №1
(the webhook URL) or any other — before it is submitted for review, and again when it is approved; a disabled or
auto-disabled endpoint does not count. Submit answers an app without one with 400 not_ready (path
webhook_url).
1. Pages in this section¶
| Page | What |
|---|---|
| Verifying signatures | The Gerege-Signature header, the algorithm, Node/Go/Python/PHP code, test vectors |
| Delivery and retries | Per-endpoint delivery, the inline attempt, the retry schedule, auto-disable, ordering |
| Idempotent handlers | Deduplicating, a fast 2xx, re-reading the object — example code |
| Event reference | Each of the 12 events: when, data.object, an example payload |
| Events API | GET /events — fetching what you missed, reconciling without webhooks |
2. Endpoints¶
An address that receives events is an endpoint. An app has up to 5 endpoints per environment (test, live); each has its own URL, description (≤ 200), event filter, its own secret, delivery log and status.
- Endpoint №1 is the app's original
webhook_url— one URL and one secret for both environments (legacy: true). The console's «Webhook» section of the app andPOST /api/v1/miniapp/dev/apps/{id}/webhook {"url"}set it. It is required for review. - Further endpoints — from the console (
/dev/apps/{id}/webhook-endpoints, you pick the environment) or from your server through the/webhook-endpointsAPI (in the key's environment, §6 below). - Permissions (console, 1.13.0): every write needs
miniapp.develop; a live endpoint's URL, secret or deletion (endpoint №1 andPOST …/webhooktoo — they are both environments') needsminiapp.keys.liveand a sign-in within the last ten minutes (403 fresh_sign_in_required). Description, filter and switching off need no step-up. The types a filter may name, labelled mn/en:GET /api/v1/miniapp/dev/webhook-event-types. - Filter
event_types: empty = every event; otherwise ≤ 20 types (no duplicates, anything butwebhook.test). The filter is applied when an event is created — changing it later does not change deliveries of earlier events. - Status
status:active(receives),disabled(you turned it off),auto_disabled(the platform turned it off after five days of failures — Delivery). An inactive endpoint is given no new events. - The URL is
https://, ≤ 2048 characters. At delivery the address is checked after DNS: nothing is ever sent to a private, loopback or link-local address — in production the endpoint has a public IP. To test on your own machine use a tunnel (Local webhooks). - A test endpoint receives only test events, a live endpoint only live ones. Endpoint №1 is one URL for both
environments, so tell its events apart by
livemode; never fulfil anything on a test event.
Creating an endpoint (console):
POST /api/v1/miniapp/dev/apps/{id}/webhook-endpoints
Content-Type: application/json
{"environment": "live", "url": "https://api.example.mn/webhooks/gerege",
"description": "ERP", "event_types": ["payment_intent.succeeded", "refund.succeeded"]}
{"endpoint": {"id": "we_…", "object": "webhook_endpoint", "environment": "live", "livemode": true,
"url": "https://api.example.mn/webhooks/gerege", "description": "ERP",
"event_types": ["payment_intent.succeeded", "refund.succeeded"], "status": "active", "disabled_reason": null,
"legacy": false, "previous_secret_until": null, "failing_since": null, "last_success_at": null,
"created_at": "…", "updated_at": "…"},
"webhook_secret": "whsec_…", "notice": "Энэ secret-ийг дахин харуулахгүй — одоо хадгална уу"}
webhook_secret (whsec_…) is shown once, in this response (Cache-Control: no-store). The platform keeps it
sealed — if you lose it, rotate it (§5). The list (GET …/webhook-endpoints[?environment=])
adds each endpoint's last 24 hours, deliveries_24h {delivered, failing}. The endpoint error codes are on
Error codes.
3. What a request looks like¶
POST /webhooks/gerege HTTP/1.1
Content-Type: application/json
User-Agent: GeregeApp-Webhooks/1
Gerege-Event-Id: evt_Vec0000000000000000000001
Gerege-Signature: t=1792659600,v1=aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821
OpenPay-Event-Id: evt_Vec0000000000000000000001
OpenPay-Signature: t=1792659600,v1=aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821
{"id":"evt_Vec0000000000000000000001","type":"payment_intent.succeeded","created_at":"2026-10-22T09:00:00Z",
"livemode":true,"app":"esim","data":{"object":{"id":"pi_4fQ2mX8kLp0aZr7TbN3cVw9E","object":"payment_intent", …}}}
The OpenPay-* headers carry the same values under the OMAS standard's names — a backend written to OMAS
verifies unchanged. data.object has exactly the API's shape (that of GET /payment-intents/{id}, for example). An
event owed to several endpoints has the same body everywhere, signed with each endpoint's own secret.
Three things you do:
- Verify the signature — over the raw body, with that endpoint's secret (Verifying signatures).
- Answer 2xx within 10 seconds and queue the work; deduplicate on
id(Idempotent handlers). - Re-read the object from the API before fulfilling an order — a webhook is a signal, the API is the truth.
4. Events¶
A short list; each event's conditions, data.object and an example payload are in the Event reference.
type |
When | data.object |
|---|---|---|
payment_intent.succeeded |
The payment succeeded (automatic: after confirmation; manual: after capture) | PaymentIntent |
payment_intent.requires_capture |
A manual intent's money is held — capture or cancel it | PaymentIntent |
payment_intent.canceled |
Canceled; also when an app intent expires (status: "expired") |
PaymentIntent |
payment_intent.failed |
Confirmation or capture failed (failure_code) |
PaymentIntent |
payment_intent.claimed |
A person took a claimless (qr/redirect/terminal) intent |
PaymentIntent |
payment_intent.expired |
A claimless intent ran out of time | PaymentIntent |
refund.succeeded · refund.failed |
A refund finished / was refused | Refund |
app.status_changed |
The app's status or review_status changed |
AppStatus |
consent.revoked |
A person disconnected the app | Consent |
settlement.paid |
A settlement was paid to the payout account (always live) | Settlement |
webhook.test |
A test send from the console (always test) | WebhookTest |
Answer 2xx to a type you do not know and ignore it — a minor version may add events.
5. The secret and rotating it¶
| Action | What happens |
|---|---|
POST …/webhook-endpoints/{eid}/rotate-secret |
A new secret for that endpoint (shown once, in the answer). The old one keeps signing alongside for 24 hours: two v1 on every event — the new one first, then the old. The endpoint's previous_secret_until says when that ends. Deploy the new secret within that time and not one event is lost. |
Endpoint №1: POST …/webhook with a url (even unchanged) |
A new secret for №1, the old one alongside for 24 hours (in both environments). |
DELETE …/webhook-endpoints/{eid} · {"url": null} for №1 |
The endpoint and its secret are removed, with the deliveries it was still owed. The events themselves stay in the Events API. |
- The URL and the secret are read when a delivery is made — a change applies to the next retry of an old event too.
- During a switch accept both
[new, old]on your side (the SDKs take a list of secrets), and drop the old one once the deploy is done. Each endpoint's secret is different — if two endpoints reach one handler, accept both. - A suspended app's endpoints cannot be changed (
409 suspended); nor can endpoint №1's URL or secret while the app is in review (409 locked_for_review). - An installation with no key to seal secrets answers
503 no_secret_key— a secret is never stored in the clear. - Every change is written to the app's audit log; the secret itself never is.
6. Managing endpoints from your server — /webhook-endpoints¶
For IaC or a deploy script: https://geregeapp.mn/api/v1/miniapp/webhook-endpoints, Basic (an API key), scope
webhooks:write — a scope only live keys are given; a test key gets 403 live_only (manage test endpoints in
the console). A key sees and creates endpoints of its own environment only (leave environment out, or give the
key's; another one is 403 environment_mismatch).
| Method | Path | |
|---|---|---|
| GET · POST | /webhook-endpoints |
{"data": [WebhookEndpoint], "next_cursor": null} · 201 {endpoint, webhook_secret, notice} |
| GET · PATCH · DELETE | /webhook-endpoints/{eid} |
PATCH: url, description, event_types, status (active | disabled) · DELETE → 204 |
| POST | /webhook-endpoints/{eid}/rotate-secret |
200 {endpoint, webhook_secret, notice} |
| POST | /webhook-endpoints/{eid}/test |
Sends a signed webhook.test to that endpoint now and answers with the outcome (20/min) |
The console does the same under /dev/apps/{id}/webhook-endpoints…, in either environment. Turn an auto_disabled
endpoint back on with PATCH {"status": "active"} (after fetching what it missed from the Events API).
Keys and scopes — Security model; every route — API conventions.