Skip to content

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 and POST /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-endpoints API (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 and POST …/webhook too — they are both environments') needs miniapp.keys.live and 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 but webhook.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:

  1. Verify the signature — over the raw body, with that endpoint's secret (Verifying signatures).
  2. Answer 2xx within 10 seconds and queue the work; deduplicate on id (Idempotent handlers).
  3. 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.