Webhooks
Receive Muin events on your own server: create an outgoing webhook, the events that deliver today, the payload and headers, signature checks and retries.
An outgoing webhook makes Muin send an HTTP POST to your server each time a chosen event happens, so your system does not have to poll.
When you’d use this
- Update your website or CRM when an invoice is paid or an event registration comes in.
- Start your own processing when a document finishes extraction.
- Keep an external attendance list in step with check-ins.
How it works
- You create a webhook with your URL and the events you want.
- When one of those events happens, Muin sends a signed
POSTto your URL. - Your server checks the signature, answers with a
2xxstatus, and does its work. - If your server fails, Muin retries, then pauses the webhook if failures continue.
Create a webhook
In the app
- Open Settings → Integrations → Webhooks, select outgoing, and click Create Webhook.
- Enter a Name and your URL, then tick the events you want.
- Copy the Webhook Secret. It is shown once.
Field-by-field steps, including incoming webhooks: Setting Up Webhooks.
With the API
Use an API key that has the Write scope (Authentication).
curl -X POST https://muin-api.falaah.ai/api/v1/webhooks/outgoing \
-H "Authorization: Bearer muin_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Website sync",
"url": "https://your-server.example/webhooks/muin",
"subscribed_events": ["invoice.paid", "registration.created"],
"description": "Production webhook"
}'
subscribed_events must list at least one event. Optional fields: custom_headers (extra headers sent with every delivery) and max_retries (0 to 10, default 5).
The response carries the webhook and its signing secret, shown once:
{
"webhook": {
"id": "6f1c9a52-3d0e-4b7a-9a43-2f0d8e1b7c11",
"name": "Website sync",
"url": "https://your-server.example/webhooks/muin",
"subscribed_events": ["invoice.paid", "registration.created"],
"max_retries": 5,
"is_active": true,
"health_status": "healthy"
},
"secret": "whsec_..."
}
Events that deliver today
Only these events reach a webhook. The event picker in the app offers exactly this list.
| Event | Fires when |
|---|---|
document.processed |
A document finishes processing |
invoice.created |
An invoice is created |
invoice.approved |
An invoice is approved |
invoice.paid |
An invoice is paid |
calendar.event.published |
An event is published to your public calendar |
calendar.event.unpublished |
A published event is taken down |
calendar.event.cancelled |
A published event is cancelled |
registration.created |
Someone registers for an event |
registration.cancelled |
A registration is cancelled |
registration.checked_in |
A registrant is checked in |
registration.no_show |
A registrant is marked as a no-show |
event.capacity_full |
An event reaches capacity |
The machine-readable list, with a deliverable flag on every event, is at GET /api/v1/integrations/events/catalog. Events marked deliverable: false are declared but nothing sends them to webhooks yet, and a test send for one is refused.
Payload
Every delivery has the same envelope:
{
"id": "evt_8sQ1o2k3HhYz0m4pX7rT1w",
"type": "invoice.paid",
"created": "2026-10-05T17:30:28.000000+00:00",
"data": {
"...": "event-specific fields"
}
}
Headers
| Header | Description |
|---|---|
Content-Type |
application/json |
X-Muin-Event |
Event type, for example invoice.paid |
X-Muin-Delivery-Id |
Unique delivery ID. Use it to ignore duplicates |
X-Muin-Signature |
HMAC-SHA256 signature, t=<unix>,v1=<hex> (see below) |
X-Muin-Timestamp |
Unix time the delivery was signed |
Any custom_headers you set on the webhook are added to these.
Verifying Signatures
Every webhook Muin sends includes an X-Muin-Signature header. That covers both senders:
event-subscription webhooks (the ones you create above) and workflow webhook actions, which post
to a URL on your organization’s allowed list. Both use the scheme below and the same verification
code, so you write the check once.
The one exception: a workflow allowed-URL entry registered before per-destination signing existed
has no secret yet, and its deliveries carry no signature. Rotate its secret once
(POST /api/v1/workflows/allowed-urls/{id}/rotate-secret) and every later delivery to that
destination is signed.
The header
X-Muin-Signature: t=1742049028,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Muin-Timestamp: 1742049028
t is the unix-second timestamp and v1 is the hex HMAC. The timestamp is part of the signed
material, so a captured delivery cannot be replayed with a newer date.
How to Verify
- Split the header on
,and read thetandv1values - Concatenate the timestamp and the RAW request body:
{t}.{body} - Compute HMAC-SHA256 over that string using your webhook secret
- Compare the hex digest with
v1, using a constant-time comparison - Reject a delivery whose
tis more than five minutes away from your own clock
Verify against the raw body bytes exactly as received. Parsing the JSON and re-serializing it changes the characters and the signature will not match.
Example (Node.js)
const crypto = require('crypto');
function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2))
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(parts.v1);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Example (Python)
import hashlib
import hmac
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, provided = parts.get("t"), parts.get("v1")
if not timestamp or not provided:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, provided)
Where each secret comes from
| Sender | Secret |
|---|---|
| Event-subscription webhooks | Shown once when you create the webhook; regenerate it from Settings → Integrations → Webhooks (Regenerate Secret) |
| Workflow webhook actions | Shown once when you add the allowed URL; rotate it with POST /api/v1/workflows/allowed-urls/{id}/rotate-secret |
Workflow secrets are held per destination, not per organization, so one partner’s secret never verifies another partner’s deliveries.
Retries and pauses
Muin waits up to 30 seconds for your server. Anything other than a 2xx answer counts as a failure.
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after attempt 1 |
| 3 | 2 minutes after attempt 2 |
| 4 | 4 minutes after attempt 3 |
| 5 | 8 minutes after attempt 4 |
That is the default of 5 attempts; max_retries changes it. After the last attempt fails, the delivery is marked failed.
If 5 deliveries in a row fail, the webhook is paused for 30 minutes (the app shows Circuit Open) and then resumes on its own.
Failed deliveries
Open Settings → Integrations → Webhooks, open the webhook, and look at Recent Deliveries. Each row shows the event, status, attempts and your server’s HTTP status code. The API’s delivery list also returns the error message. Retry a failed delivery from there, or with the API below.
Managing webhooks with the API
All paths are on https://muin-api.falaah.ai.
| Action | Request |
|---|---|
| List webhooks | GET /api/v1/webhooks/outgoing |
| Get one | GET /api/v1/webhooks/outgoing/{webhook_id} |
| Change name, URL, events, headers, or turn on/off | PATCH /api/v1/webhooks/outgoing/{webhook_id} with any of name, description, url, subscribed_events, custom_headers, is_active |
| Send a test delivery | POST /api/v1/webhooks/outgoing/{webhook_id}/test |
| List deliveries | GET /api/v1/webhooks/outgoing/{webhook_id}/deliveries |
| Retry a failed delivery | POST /api/v1/webhooks/outgoing/{webhook_id}/deliveries/{delivery_id}/retry |
| Replace the signing secret | POST /api/v1/webhooks/outgoing/{webhook_id}/regenerate-secret |
| Delete | DELETE /api/v1/webhooks/outgoing/{webhook_id} |
Regenerating the secret takes effect immediately. There is no overlap period, so update your server’s verification at the same moment.
When it goes wrong
| What you see | Why | What to do |
|---|---|---|
| Signatures never match | Verifying a re-serialized body, or using an old secret. | Hash the raw body bytes with the current secret. |
| An event never arrives | It is not in the list of events that deliver today. | Subscribe to a deliverable event. |
| Deliveries stopped for a while, then resumed | The webhook paused for 30 minutes after 5 failed deliveries in a row. | Fix the endpoint, then retry the failed deliveries. |
403 from the API |
Your API key lacks the Write scope, or the person who created it cannot manage webhooks. | Use a key with Write, created by an owner or admin. |
Best practices
- Answer fast. Return
2xxright away and do slow work in the background. - Expect duplicates. Use
X-Muin-Delivery-Idto process each delivery once. - Always verify the signature before acting on a delivery.
- Use HTTPS for your endpoint.
Next step
- Authentication: create the API key these calls need.
- Setting Up Webhooks: the same setup in the app, including incoming webhooks.