Webhooks
Migrando de um evento legado? Veja o guia de migração.
Webhooks are how Moonip pushes status updates to your backend in real time. Use them for fulfillment — never rely on polling alone. If a delivery is missed, use the pull/reconciliation API to catch up.
#0. Start here (most common questions)
#One URL or one URL per event?
One URL. In the dashboard (Integrations → Webhooks → New endpoint) you register one HTTPS endpoint and check multiple events. Moonip sends a separate POST for each occurrence, always to the same URL.
| Some other PSPs | Moonip |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type) | https://your-shop.com/hooks/moonip + event list at registration |
| One HTTP route per event | One route, many events; the type field (or legacy event) distinguishes them |
Recommended generic URL:
https://your-domain.com/hooks/moonipAvoid paths like /api/charge/created unless your router requires it — the path does not select the event; the dashboard checkboxes (or the events array in the API) do.
#What does “all events on one URL” look like?
You do not receive one payload with every event type. You receive one POST per status change. Branch on the type:
const type = body.type || body.event; // canonical uses type; legacy uses event
switch (type) {
case 'charge.paid':
// fulfill order
break;
case 'charge.failed':
case 'charge.expired':
// cancel / expire
break;
case 'charge.refunded':
// handle refund
break;
case 'med_case.created':
case 'med_case.updated':
case 'med.resolved':
// Pix dispute (MED) — see "MED events" below
break;
default:
// unknown type: 200 OK and ignore
}
res.sendStatus(200); // respond 2xx quickly#Refunds — which event?
| Need | Event | Family |
|---|---|---|
| Charge paid | charge.paid | canonical |
| Charge failed / expired | charge.failed / charge.expired | canonical |
| Charge refund | charge.refunded | canonical |
| Payout settled / failed | payout.paid / payout.failed | canonical (not a refund) |
| Pix dispute (MED) opened / changed / resolved | med_case.created / med_case.updated / med.resolved | canonical / canonical / legacy envelope (not a refund) |
Subscribe to charge.refunded on the same endpoint if you need refunds — it fires for every refund: the ones you (or the Moonip team, on your behalf) issue, and the automatic payer-CPF devolução.
A MED is not a refund: when the payer's bank opens a MED, no refund is created and charge.refunded does not fire — neither when the MED opens nor when it is won or lost. Follow disputes with the MED events.
The refund object carries the refunded amount and its origin:
{
"id": "evt_…",
"object": "event",
"type": "charge.refunded",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "refunded",
"payment_method": null,
"amount_refunded": 1000,
"settlement": { "end_to_end_id": "E0000…" },
"refund": {
"amount": 1000,
"currency": "BRL",
"origin": "MERCHANT",
"reason": "customer request",
"end_to_end_id": "E0000…"
}
}
}
}refund.origin is MERCHANT for a merchant/admin-initiated refund or AUTOMATIC_PAYER_RESTRICTION for the automatic Pix devolução triggered when a settled payer's CPF/CNPJ did not match the charge's expected payer. amount_refunded is the total refunded so far (equal to refund.amount for a single refund; larger across partial refunds). On this event data.object.amount is the original charge total (amount_refunded is the refunded part of it), and payment_method is always null (the refund event carries no payment method). Refund events emitted before the original total existed are the exception: they deliver amount: 0, so read the charge if you need the original total of an old refund.
#Several accounts / shops on the same URL
Fine. Each Moonip account has its own keys and endpoints, but your server URL can be shared. Segment using the payload (data.object.id = ch_…, metadata you set when creating the charge, etc.).
#Dashboard path (no API)
- Integrations → Webhooks → New endpoint
- Generic HTTPS URL (e.g.
…/hooks/moonip) - Check at least:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedif you need refunds) - Save the secret (shown once)
- Click Test and confirm your server returned 2xx
#1. The canonical event envelope
New integrations subscribe to the canonical events (charge.*, payout.*, med_case.*). Every delivery is an HTTPS POST to your registered URL with this shape:
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"settlement": {}
}
}
}id(evt_…) is the business-event id — stable across every delivery attempt and every endpoint fan-out. Deduplicate on it.data.objectis a public, allowlistedcharge/payout/med_caseobject (no provider name, cost, secret, or raw PSP payload can appear here), but it is not always the full REST shape:med_case.*is the only true parity — the same object asGET /v1/med/cases/{id};charge.createdis the richestchargepayload (it may carrycustomer,pix,metadata,created_at);charge.paid/charge.failed/charge.expireddeliver a reduced charge — nocustomer,pix,metadataorcreated_at;payout.*delivers a reduced payout — noamount,currencyorfee(onlyid,object,statusand, on failure,failure_code/failure_message).statusis one ofpending,processing,paid,failed.
settlementis{}on everycharge.*exceptcharge.refunded, which is the only event that may fillsettlement.end_to_end_id.- Always handle unknown
typevalues gracefully (200 OK+ ignore) so adding new event types never breaks you.
#Event catalog
GET /v1/webhooks/event-catalog returns the live, authoritative list. It requires your
API key like every other route under /v1/webhooks (only the provider-inbound routes are
open):
curl https://api.moonip.io/v1/webhooks/event-catalog \
-H "Authorization: Bearer $MOONIP_API_KEY"| Event | When it fires |
|---|---|
charge.created | Cobrança criada (Pix/cartão gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada (Pix/cartão liquidado). |
charge.failed | Cobrança falhou ou foi cancelada. |
charge.expired | Cobrança expirou sem pagamento. |
charge.refunded | Cobrança reembolsada (total ou parcial) — reembolso do lojista ou devolução automática (trava de CPF). Não dispara para MED (use med_case.* e med.resolved). |
payout.created | Saque solicitado. |
payout.approved | Payout approved, awaiting settlement. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
med_case.created | Caso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar. |
med_case.updated | Caso MED mudou — contestado, aceito, encerrado, evidências pedidas. |
med.resolved | MED (devolução pedida pelo pagador) resolvida — informa o destino do valor: devolvido ao lojista, encerrado sem devolução ou encerrado como perda. |
The live catalog is available at GET /webhooks/event-catalog. It contains canonical charge and payout names, MED events, KYC and account lifecycle events.
#Why a payout failed
payout.failed carries the reason as a stable code: data.object.failure_code
(lowercase, e.g. destination_unavailable, invalid_pix_key,
provider_rejected) plus data.object.failure_message, a neutral sentence for
that code.
A multi-leg payout that settles only some legs sends no webhook. When a split payout finishes only partially (internal status
PARTIALLY_COMPLETED), nopayout.*event is delivered at all — the status you can act on is the one you read back from the API, or from the pull API.
Disputes (MED) do not fire a
charge.*webhook — the charge was paid, and a MED is neither a charge failure nor a refund, so neithercharge.failednorcharge.refundedis sent. Subscribe to the MED events instead; re-reading the charge showsstatus: "disputed"while the case is open. See Disputes and MED.
A late
charge.paidcan follow acharge.expired. If the acquirer confirms a payment only after the charge already expired (delayed webhook / lagging status API), Moonip checks the payment and, once confirmed, deliverscharge.paidfor that same charge. The latercharge.paidis the final state — treat it as paid, even though you had receivedcharge.expiredbefore. Never rely oncharge.expiredbeing terminal.
An expiry also reaches
charge.failedsubscribers. When a charge expires, the event fans out to both names: an endpoint subscribed only tocharge.failedreceives the expiry as acharge.failed. Do not treat everycharge.failedas a refusal — read the payload to tell them apart.
#2. Register your endpoint
Endpoint POST /v1/webhooks/endpoints
Headers
Authorization: Bearer ak_live_...
Content-Type: application/jsonThe canonical auth header is
Authorization: Bearer <key>. The legacyapikey: <key>form still works as compatibility, but new integrations should useAuthorization: Bearer.
Body
{
"url": "https://merchant.example.com/hooks/moonip",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Response 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/moonip",
"events": ["charge.expired", "charge.failed", "charge.paid"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}Save the
secretimmediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.
#Limits and permissions
| Rule | Value |
|---|---|
| Active endpoints per account | 10 with approved KYC, 2 without |
| Test deliveries per hour per account | 30 with approved KYC, 10 without |
| URL scheme | https:// outside SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED otherwise |
| URL host | may not be the platform's own domain — 400 WEBHOOK_OWN_DOMAIN |
| Duplicate URL in the account | 409 WEBHOOK_ENDPOINT_DUPLICATE |
| Over the endpoint limit | 409 WEBHOOK_ENDPOINT_LIMIT |
| Over the test limit | 429 WEBHOOK_TEST_RATE_LIMIT |
| Writes (create/update/delete/rotate-secret/test/replay) | need an OWNER or ADMIN dashboard session; view-only impersonation is refused (403 WEBHOOK_ROLE_REQUIRED / IMPERSONATION_VIEW_ONLY). An API key writes as the account itself. |
| Suspended or blocked account | cannot create, edit or test endpoints — 403 TENANT_NOT_ACTIVE |
#Canonical subscriptions
Charge and payout subscriptions use only the canonical public names. Adding retired names is rejected on create/update with HTTP 400, code LEGACY_WEBHOOK_EVENT, and an event / replacement pair. Existing subscriptions follow the platform compatibility policy; updates may retain names already subscribed.
payout.approved reports approval, before settlement. Its versioned envelope contains data.object with object: "payout", a po_ id and status: "processing". It uses the timestamped HMAC signature, like other payout events. Its event id is stable per payout, including re-publishes.
#Account/KYC events (legacy envelope only)
kyc.submitted, kyc.approved, kyc.rejected, and the account-lifecycle pair tenant.created / tenant.updated also fire and always use the legacy { event, data } envelope (they are not charge.*/payout.*, so they never get the canonical evt_ wrapper or the t=,v1= signature).
⚠️ Known naming gap: the account-lifecycle events currently emit an internal field name verbatim in their payload — a tracked glossary violation, not an intentional part of the public contract. See the migration guide's known gap and followups for the exact event and field names. Do not build a permanent integration against that field name.
#MED events
Three events follow a Pix dispute (MED) — the full flow is in Disputes and MED:
| Event | Envelope / signature | When it fires |
|---|---|---|
med_case.created | canonical evt_ / t=,v1= | A MED case opens against one of your charges. |
med_case.updated | canonical evt_ / t=,v1= | The case changes status (appealed, accepted, rejected, closed) or the platform asks you for evidence. |
med.resolved | legacy {event,data} / sha256= | The case reaches its outcome; tells where the money went. |
med_case.created / med_case.updated carry the same med_case object that
GET /v1/med/cases/{id} returns, in data.object:
{
"id": "evt_9c2f4e1a7b3d5c8e0f1a2b3c4d5e6f70",
"object": "event",
"api_version": "2026-07-23",
"type": "med_case.created",
"created_at": "2026-09-23T14:02:12.000Z",
"data": {
"object": {
"id": "med_3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"object": "med_case",
"type": "med",
"status": "open",
"amount": 15000,
"currency": "BRL",
"charge_id": "ch_6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"charge_metadata": { "order_id": "PED-10442" },
"customer_name": "Maria Silva",
"reason": "Payer reports a scam",
"response_deadline": "2026-09-30T23:59:59.000Z",
"evidence_requested_at": null,
"appeal_reason": null,
"appealed_at": null,
"evidence_count": 0,
"money_outcome": null,
"opened_at": "2026-09-23T14:02:11.000Z",
"closed_at": null,
"created_at": "2026-09-23T14:02:12.000Z",
"updated_at": "2026-09-23T14:02:12.000Z"
}
}
}A med_case.updated has the same shape, with type: "med_case.updated" and the case as it
is after the change (for example status: "closed" and money_outcome: "refunded_to_merchant").
med.resolved uses the legacy {event, data} body and the sha256= signature:
{
"event": "med.resolved",
"data": {
"infractionId": "3f1c2b9e-0d7a-4a55-9a51-6f0e3c1d2b10",
"transactionId": "6b0f4c1e-2a8d-4c1b-9d0e-1f2a3b4c5d6e",
"amount": 15000,
"currency": "BRL",
"outcome": "closed_without_refund",
"occurredAt": "2026-09-11T14:32:07.000Z"
}
}infractionIdis the case id without themed_prefix andtransactionIdis the charge id without thech_prefix:med_+infractionIdis theidyou use in/v1/med/cases/{id}.outcomeisrefunded_to_merchant(the held amount came back to your balance),closed_without_refund(closed, the amount did not come back) orlost(the amount stays debited). It is the same value as the case'smoney_outcome.- The same outcome is never announced twice; a different outcome (a correction) is.
- Accepting a case yourself does not send
med.resolved— themed_case.updatedwithstatus: "accepted"tells you.
Outcome notifications (med.resolved, and med_case.updated for accepted / rejected /
closed) may not arrive: a MED event that fails to be published is not retried (delivery to your endpoint, once published, follows the
normal retry schedule). Treat these events as a
signal and reconcile with GET /v1/med/cases — the API is the source of truth.
#3. Verify the signature
Every delivery is signed with HMAC-SHA256 over the raw request body using your endpoint's secret. There are two schemes, chosen automatically by event family — your verifier should support both if you have any legacy subscription active.
| Header | Scheme | Applies to |
|---|---|---|
X-Moonip-Signature | t=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>" | Canonical events (charge.*, payout.*, med_case.*) |
X-Moonip-Signature | sha256=<hex> — signed payload is the raw body alone | Legacy events (kyc.*, med.resolved, and the account-lifecycle events above) |
X-Moonip-Delivery-Id | Opaque id, stable across every retry of the same delivery | Both |
X-Moonip-Event-Type | Mirrors the delivered event name | Both |
X-Moonip-Test | true, only on a test delivery; absent on every real one | Both |
The canonical scheme embeds a timestamp in the signed material specifically so you can reject replays outside a tolerance window (recommended: 5 minutes) — the legacy scheme has no timestamp and cannot do this.
#Node.js (Express) — canonical scheme
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.MOONIP_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyCanonical(rawBody, header, secret) {
const [tPart, v1Part] = header.split(',');
const t = Number(tPart?.split('=')[1]);
const v1 = v1Part?.split('=')[1];
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/moonip',
// capture raw body — express.json() strips it
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Moonip-Signature') || '';
if (!verifyCanonical(req.body.toString('utf8'), signature, SECRET)) {
return res.status(401).send('invalid signature');
}
const { id: eventId, type, data } = JSON.parse(req.body.toString('utf8'));
// 200 OK FAST. Do work in a background queue.
res.status(200).end();
queue.enqueue({ eventId, type, data, deliveryId: req.header('X-Moonip-Delivery-Id') });
},
);Prefer not to hand-roll this? The official SDKs verify both schemes (canonical and legacy) for you. The recommended entry point passes the headers map: the SDK picks the signature header by name (an option), or auto-detects the single header whose name ends in
-signature— which is exactly the header your brand's deliveries use. Seepackages/sdk-node/README.md.
// node — @moonip/node
import { MoonipClient } from '@moonip/node';
const client = new MoonipClient({ apiKey: process.env.MOONIP_API_KEY!, baseUrl: 'https://api.moonip.io' });
// headers-map form: auto-detects the one `*-signature` header of this brand
if (!client.webhooks.verify(rawBody, headers, secret)) return res.status(401).end();
// or pin the header name explicitly (also settable once as `webhookSignatureHeader`)
client.webhooks.verify(rawBody, headers, secret, { signatureHeader: 'X-Moonip-Signature' });
client.webhooks.parse(rawBody, headers, secret);
// 0.8.0 management surface
await client.webhooks.endpoints.test(endpointId); // POST /v1/webhooks/endpoints/:id/test
await client.webhooks.events({ since, limit: 50 }); // GET /v1/webhooks/events (pull)# python — moonip
ok = client.webhooks.verify(raw_body, headers, secret) # auto-detect
ok = client.webhooks.verify(raw_body, headers, secret,
signature_header='X-Moonip-Signature') # explicit name
event = client.webhooks.parse(raw_body, headers, secret)
client.webhooks.endpoints.test(endpoint_id)
client.webhooks.events(since=..., limit=50)// php — moonip/sdk-php
$ok = $client->webhooks->verify($rawBody, $headers, $secret); // array = headers map, auto-detect
$ok = $client->webhooks->verify($rawBody, $headers, $secret, 300, null, 'X-Moonip-Signature');
$event = $client->webhooks->parse($rawBody, $headers, $secret);
$client->webhooks->endpoints->test($endpointId);
$client->webhooks->events(['since' => $since, 'limit' => 50]);// dotnet — Moonip
bool ok = client.Webhooks.Verify(rawBody, headers, secret); // dictionary = headers map
bool ok2 = client.Webhooks.Verify(rawBody, headers, secret, 300, "X-Moonip-Signature");
var evt = client.Webhooks.Parse(rawBody, headers, secret);
await client.Webhooks.Endpoints.TestAsync(endpointId);
await client.Webhooks.EventsAsync(); // optional ListWebhookEventsQueryResolution is identical in the four: an explicit signatureHeader (name match,
case-insensitive) wins; without one, the SDK uses the single header whose lowercased
name ends with -signature, and fails when there are zero or two such headers — it never
guesses. A bare signature string still works and needs no header name.
#PHP — unversioned KYC and account events
$secret = getenv('MOONIP_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_MOONIP_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expect, $sig)) {
http_response_code(401);
exit('invalid signature');
}
$body = json_decode($raw, true);
http_response_code(200);
// queue $body for processing#Python (Flask) — legacy scheme
import hmac, hashlib, os
from flask import request, abort
SECRET = os.environ['MOONIP_WEBHOOK_SECRET'].encode()
@app.post('/hooks/moonip')
def moonip_hook():
raw = request.get_data() # bytes, untouched
sig = request.headers.get('X-Moonip-Signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = request.get_json()
# respond 200 fast, process async
return '', 200#4. Delivery guarantees, retries and DLQ
- Success Any
2xxresponse confirms the delivery and stops retries. - Timeout 30 seconds (10 for a test delivery). A slower response counts as a failure.
- Retry schedule Exponential backoff (
1s × 2^attempt), capped at 6 hours between attempts (WEBHOOK_BACKOFF_CAP_MS, default21_600_000). - Maximum attempts 15 (
WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves toCANCELLED(dead-lettered) — it is not deleted, and can be replayed manually. - Changing your endpoint Failing deliveries follow the endpoint you have registered now. Add an endpoint (or re-activate one) and the deliveries that are failing on your other endpoints move to it. Deactivate or delete an endpoint, or stop subscribing to an event, and whatever was still queued for it moves to your other active endpoints subscribed to the same event — or is cancelled if there is none. Changing an endpoint's
urlretries its failing deliveries right away, with a fresh attempt budget. A moved delivery gets a newX-Moonip-Delivery-Id; the canonical event'sid(evt_…) stays the same. - 429 handling If your endpoint returns
429with aRetry-Afterheader (seconds) or a JSON body{ "retry_after": <seconds> }, that value is honored (capped at 5 minutes) instead of the blind exponential backoff. - Delivery id stability
X-Moonip-Delivery-Idis identical across every attempt of the same delivery — use it as your primary dedup key.
⚠️ A delivery may arrive more than once. Always make your handler idempotent by deduplicating on
X-Moonip-Delivery-Id(or the canonical event'sid/evt_…, which stays the same when a delivery moves to another endpoint).
#Idempotent handler pattern
async function handle({ deliveryId, eventId, type, data }) {
// Atomic insert — fails if we've seen this delivery before
const inserted = await db.processedWebhooks.insertIgnore({
id: deliveryId ?? eventId,
receivedAt: new Date(),
});
if (!inserted) return; // already handled
if (type === 'charge.paid') {
await orders.markPaid(data.object.id, data.object);
}
}#5. Pull / reconciliation API
If a push was missed (your receiver was down for hours), pull the events you missed instead of losing them.
curl "https://api.moonip.io/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "Authorization: Bearer $MOONIP_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "deliveredAt": "2026-07-23T14:31:05.000Z", "createdAt": "2026-07-23T14:31:00.000Z" }
],
"nextCursor": "MjAyNi0wNy0yM1QxNDozMTowMC4wMDBafGRlbF8xMjM="
}- Cursor pagination on
(createdAt desc, id desc)— passnextCursorback ascursorfor the next page; treat it as an opaque token. - Filters:
since,until(ISO 8601),status,eventType. - Scoped strictly to your own endpoints — never returns platform-internal deliveries.
#6. Testing an endpoint
POST /v1/webhooks/endpoints/:id/test sends a synchronous, non-persisted sample delivery so you can check status, latency, and signature handling.
curl -X POST "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $MOONIP_API_KEY"The test delivery sends a canonical
charge.paidsample with the versioned envelope and timestamped signature.
#Test deliveries
Some deliveries are tests, not business events:
- the Test button above;
- the payment of a test charge (created with a
ak_test_key or in the dashboard's Test environment); - the simulated payout of the Test environment, which sends the webhook of a completed payout that never existed.
Test deliveries carry X-Moonip-Test: true. The Test button also stamps metadata.test: true in the charge object. Never fulfill an order or credit a customer from a test delivery.
#7. Production checklist
- Endpoint is HTTPS with a valid TLS certificate.
- Signature is verified on the raw body, before JSON parsing.
- Deliveries with
X-Moonip-Test: truenever fulfill an order. - Comparison uses constant-time (e.g.
timingSafeEqual/hash_equals/hmac.compare_digest). - Your verifier supports both signature schemes if any endpoint is still subscribed to a legacy event.
- Handler responds
2xxin under 5 seconds. Heavy work goes to a queue. - Idempotency on
X-Moonip-Delivery-Id(orevt_…for canonical events). - Unknown
type/eventnames are ignored gracefully (200 OK, no error). - Secret is loaded from a secret manager — never committed.
- An alert fires if no webhook is received within an expected window; use the pull API as a backstop.
#8. Operations
#List endpoints
curl "https://api.moonip.io/v1/webhooks/endpoints" \
-H "Authorization: Bearer $MOONIP_API_KEY"{
"data": [
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/moonip",
"events": ["charge.paid", "charge.failed"],
"status": "ACTIVE",
"createdAt": "2026-07-23T14:31:00.000Z",
"updatedAt": "2026-07-23T14:31:00.000Z"
}
],
"access": { "canManage": true },
"httpsOnly": true
}Rows never include the secret. access.canManage says whether you may write (create,
update, delete, rotate, test, replay) from this caller; httpsOnly says whether the
environment enforces https:// on endpoint URLs.
#List recent deliveries
curl "https://api.moonip.io/v1/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer $MOONIP_API_KEY"Each entry includes the attempt count, last status code, the delivered payload, the
lastError text and the next retry timestamp. The last response body is not here — it
is only on the delivery detail. Optional filters: status,
eventType, endpointId, limit (default 25, max 100), offset.
#Inspect one delivery
curl "https://api.moonip.io/v1/webhooks/deliveries/<DELIVERY_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY"Returns the full delivery record — including lastResponseBody, paymentMethod and
test — plus the attempt log and the endpoint it belongs to:
{
"id": "…",
"eventType": "charge.paid",
"status": "FAILED",
"attempts": 3,
"lastStatusCode": 500,
"lastError": "HTTP 500",
"lastResponseBody": "{\"error\":\"…\"}",
"logs": [
{
"id": "…",
"deliveryId": "…",
"attempt": 1,
"statusCode": 500,
"error": "HTTP 500",
"responseBody": "{\"error\":\"…\"}",
"duration": 120,
"url": "https://merchant.example.com/hooks/moonip",
"createdAt": "2026-07-23T14:31:00.000Z"
}
],
"endpoint": { "url": "https://merchant.example.com/hooks/moonip", "status": "ACTIVE" }
}logs lists every attempt in order. A delivery that was moved to another endpoint carries
cancelReason (ENDPOINT_REPLACED, ENDPOINT_INACTIVE or ENDPOINT_DELETED) and, when
it was superseded, supersededById.
#Replay a failed or cancelled delivery
curl -X POST "https://api.moonip.io/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "Authorization: Bearer $MOONIP_API_KEY"Resets the attempt counter and requeues immediately. Bulk replay is available at POST /v1/webhooks/deliveries/replay-bulk with an optional { status, endpointId, limit } filter (default limit 50, max 500; its status defaults to FAILED + CANCELLED).
Filter semantics differ between reads and bulk replay. GET /deliveries and
GET /events silently ignore an invalid status, an invalid date or a malformed
cursor (you get an unfiltered page). POST /deliveries/replay-bulk, instead, answers
400 on an unknown status. Known issue (documented as-is): bulk replay also accepts
status: DELIVERED or PROCESSING and re-queues those deliveries, while the single
replay above refuses both with 400.
#Rotate a secret
curl -X POST "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $MOONIP_API_KEY"The new secret is returned once.
Rotation is an instantaneous server-side swap — every webhook Moonip signs after a successful rotate-secret call uses the new secret. There is no server-side overlap window.
To rotate without dropped events, your verifier must temporarily accept both the old and new secret during your deploy:
// Try new first, fall back to old. Drop OLD_SECRET after the deploy soaks.
const ok = verify(req, NEW_SECRET) || verify(req, OLD_SECRET);
if (!ok) return res.status(401).end();Order of operations:
- Call rotate-secret → store the new secret alongside the old.
- Deploy your verifier with both secrets active.
- Soak for at least one minute (any in-flight retries clear).
- Remove the old secret on the next deploy.
#Update or delete an endpoint
curl -X PATCH "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://merchant.example.com/hooks/moonip-v2", "status": "ACTIVE"}'
curl -X DELETE "https://api.moonip.io/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "Authorization: Bearer $MOONIP_API_KEY"PATCH updates only the fields you send (url, events, status: ACTIVE/INACTIVE) and never returns the secret. DELETE deactivates the endpoint for good: it leaves your list and receives nothing else, while its delivery history is kept. Deliveries still queued for it move to your other active endpoints subscribed to the same event — see Delivery guarantees.
#Stats
curl "https://api.moonip.io/v1/webhooks/stats" \
-H "Authorization: Bearer $MOONIP_API_KEY"Returns delivery counts by state (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) plus total.
#FAQ
Q: Can I have multiple endpoints? A: Yes. Register as many as you want — useful for separating staging, production, and observability sinks.
Q: Can an endpoint mix canonical and legacy events?
A: Yes. events on a single endpoint can include both families (e.g. ["charge.paid", "kyc.approved"]); each delivered event uses the envelope/signature scheme that matches its own name.
Retired webhook subscriptions Retired charge and payout names cannot be added to subscriptions. Existing subscriptions keep their message format and signature. The dashboard shows saved retired names with a removal notice and their replacement; merchants can remove them and select a canonical name. Canonical names use the versioned envelope and timestamped signature.