Skip to content
MOONIPdocs
IntegrationManual
PTEN
Go to dashboard

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 PSPsMoonip
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type)https://your-shop.com/hooks/moonip + event list at registration
One HTTP route per eventOne route, many events; the type field (or legacy event) distinguishes them

Recommended generic URL:

text
https://your-domain.com/hooks/moonip

Avoid 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:

js
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?

NeedEventFamily
Charge paidcharge.paidcanonical
Charge failed / expiredcharge.failed / charge.expiredcanonical
Charge refundcharge.refundedcanonical
Payout settled / failedpayout.paid / payout.failedcanonical (not a refund)
Pix dispute (MED) opened / changed / resolvedmed_case.created / med_case.updated / med.resolvedcanonical / 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:

json
{
  "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)

  1. Integrations → Webhooks → New endpoint
  2. Generic HTTPS URL (e.g. …/hooks/moonip)
  3. Check at least: charge.created, charge.paid, charge.failed, charge.expired (+ charge.refunded if you need refunds)
  4. Save the secret (shown once)
  5. 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:

json
{
  "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.object is a public, allowlisted charge/payout/med_case object (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 as GET /v1/med/cases/{id};
    • charge.created is the richest charge payload (it may carry customer, pix, metadata, created_at);
    • charge.paid / charge.failed / charge.expired deliver a reduced charge — no customer, pix, metadata or created_at;
    • payout.* delivers a reduced payout — no amount, currency or fee (only id, object, status and, on failure, failure_code/failure_message). status is one of pending, processing, paid, failed.
  • settlement is {} on every charge.* except charge.refunded, which is the only event that may fill settlement.end_to_end_id.
  • Always handle unknown type values 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):

bash
curl https://api.moonip.io/v1/webhooks/event-catalog \
  -H "Authorization: Bearer $MOONIP_API_KEY"
EventWhen it fires
charge.createdCobrança criada (Pix/cartão gerado, aguardando pagamento).
charge.paidCobrança paga e confirmada (Pix/cartão liquidado).
charge.failedCobrança falhou ou foi cancelada.
charge.expiredCobrança expirou sem pagamento.
charge.refundedCobranç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.createdSaque solicitado.
payout.approvedPayout approved, awaiting settlement.
payout.paidSaque liquidado com sucesso.
payout.failedSaque falhou ou foi rejeitado.
med_case.createdCaso MED aberto contra uma cobrança sua — traz o valor contestado e o prazo para contestar.
med_case.updatedCaso MED mudou — contestado, aceito, encerrado, evidências pedidas.
med.resolvedMED (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), no payout.* 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 neither charge.failed nor charge.refunded is sent. Subscribe to the MED events instead; re-reading the charge shows status: "disputed" while the case is open. See Disputes and MED.

A late charge.paid can follow a charge.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, delivers charge.paid for that same charge. The later charge.paid is the final state — treat it as paid, even though you had received charge.expired before. Never rely on charge.expired being terminal.

An expiry also reaches charge.failed subscribers. When a charge expires, the event fans out to both names: an endpoint subscribed only to charge.failed receives the expiry as a charge.failed. Do not treat every charge.failed as a refusal — read the payload to tell them apart.

#2. Register your endpoint

Endpoint POST /v1/webhooks/endpoints

Headers

text
Authorization: Bearer ak_live_...
Content-Type: application/json

The canonical auth header is Authorization: Bearer <key>. The legacy apikey: <key> form still works as compatibility, but new integrations should use Authorization: Bearer.

Body

json
{
  "url": "https://merchant.example.com/hooks/moonip",
  "events": ["charge.paid", "charge.failed", "charge.expired"]
}

Response 201 Created

json
{
  "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 secret immediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.

#Limits and permissions

RuleValue
Active endpoints per account10 with approved KYC, 2 without
Test deliveries per hour per account30 with approved KYC, 10 without
URL schemehttps:// outside SANDBOX/STAGING — 400 WEBHOOK_HTTPS_REQUIRED otherwise
URL hostmay not be the platform's own domain — 400 WEBHOOK_OWN_DOMAIN
Duplicate URL in the account409 WEBHOOK_ENDPOINT_DUPLICATE
Over the endpoint limit409 WEBHOOK_ENDPOINT_LIMIT
Over the test limit429 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 accountcannot 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:

EventEnvelope / signatureWhen it fires
med_case.createdcanonical evt_ / t=,v1=A MED case opens against one of your charges.
med_case.updatedcanonical evt_ / t=,v1=The case changes status (appealed, accepted, rejected, closed) or the platform asks you for evidence.
med.resolvedlegacy {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:

json
{
  "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:

json
{
  "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"
  }
}
  • infractionId is the case id without the med_ prefix and transactionId is the charge id without the ch_ prefix: med_ + infractionId is the id you use in /v1/med/cases/{id}.
  • outcome is refunded_to_merchant (the held amount came back to your balance), closed_without_refund (closed, the amount did not come back) or lost (the amount stays debited). It is the same value as the case's money_outcome.
  • The same outcome is never announced twice; a different outcome (a correction) is.
  • Accepting a case yourself does not send med.resolved — the med_case.updated with status: "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.

HeaderSchemeApplies to
X-Moonip-Signaturet=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>"Canonical events (charge.*, payout.*, med_case.*)
X-Moonip-Signaturesha256=<hex> — signed payload is the raw body aloneLegacy events (kyc.*, med.resolved, and the account-lifecycle events above)
X-Moonip-Delivery-IdOpaque id, stable across every retry of the same deliveryBoth
X-Moonip-Event-TypeMirrors the delivered event nameBoth
X-Moonip-Testtrue, only on a test delivery; absent on every real oneBoth

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

js
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. See packages/sdk-node/README.md.

js
// 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
# 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
// 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]);
csharp
// 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 ListWebhookEventsQuery

Resolution 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

php
$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

python
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 2xx response 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, default 21_600_000).
  • Maximum attempts 15 (WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves to CANCELLED (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 url retries its failing deliveries right away, with a fresh attempt budget. A moved delivery gets a new X-Moonip-Delivery-Id; the canonical event's id (evt_…) stays the same.
  • 429 handling If your endpoint returns 429 with a Retry-After header (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-Id is 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's id / evt_…, which stays the same when a delivery moves to another endpoint).

#Idempotent handler pattern

js
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.

bash
curl "https://api.moonip.io/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
json
{
  "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) — pass nextCursor back as cursor for 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.

bash
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.paid sample 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: true never 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 2xx in under 5 seconds. Heavy work goes to a queue.
  • Idempotency on X-Moonip-Delivery-Id (or evt_… for canonical events).
  • Unknown type/event names 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

bash
curl "https://api.moonip.io/v1/webhooks/endpoints" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
json
{
  "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

bash
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

bash
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:

json
{
  "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

bash
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

bash
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:

js
// 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:

  1. Call rotate-secret → store the new secret alongside the old.
  2. Deploy your verifier with both secrets active.
  3. Soak for at least one minute (any in-flight retries clear).
  4. Remove the old secret on the next deploy.

#Update or delete an endpoint

bash
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

bash
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.