Manta handbook · 12 of 12
Webhook Notifications: Payload Format and Signature Verification
What Manta sends to your webhook endpoint: payload, event types, signature verification, retries and troubleshooting. Everything you need to build a receiver.
This page describes the HTTP requests Manta AI sends to a webhook endpoint you've connected under Settings → Integrations → Webhooks. Use it when you're building the endpoint that receives them.
Contents
- Before you start
- Request
- Body
- Event types
- Event reference and examples
- What gets sent, and to whom
- Verifying the signature
- Responding to a delivery
- Retries and delivery guarantees
- Sending a test message
- Managing your signing secret
- Troubleshooting
Before you start
- Paid plan required. Webhooks (and Slack) are available on paid subscriptions. The free plan and trials are not included. If your organization drops to the free plan, deliveries stop until it's upgraded again.
- Public HTTPS endpoint. Your URL must:
- use
https://(plainhttp://is rejected), - not embed credentials (
https://user:pass@host/...is rejected), - resolve to a publicly routable address.
localhost, private ranges (10.x,172.16–31.x,192.168.x), loopback, link-local (169.254.x), carrier-grade NAT (100.64.x) and other reserved ranges are blocked. - The hostname is re-resolved and re-checked on every delivery, not just when you save the integration. If a hostname later starts resolving to a private address, deliveries fail with
Blocked: ….
- use
- Local development. Because private addresses are blocked, you can't point Manta AI at
localhost. Use a public HTTPS tunnel (for example ngrok or Cloudflare Tunnel) while developing. - Routing is configured per category. Creating an integration doesn't send anything by itself. You also add a route that chooses which categories (
RUN,SCHEDULE) it receives.
Request
- Method:
POST - Content-Type:
application/json - Custom headers: exactly one,
X-Manta-Signature(see Verifying the signature). There is no delivery-ID or event-ID header. - Timeout: Manta AI waits up to 10 seconds for your endpoint to respond. A slower response is treated as a failure and retried. Respond quickly (2xx) and do any slow work asynchronously.
- Body size: small, a single JSON object. Size varies slightly with text such as schedule names, so don't assume a fixed length.
Body
{
"type": "RUN_FAILED",
"category": "RUN",
"title": "Run Failed",
"message": "Run #R-x7Kq2mPa has failed.",
"metadata": {
"runId": "b3f1c2a0-5d7e-4c1a-9e2f-0a1b2c3d4e5f"
},
"timestamp": "2026-09-19T14:32:07.104Z",
"target": null
}
type(string): the specific event, one of the values under Event types. It's a stable identifier, safe to switch on. New types may be added over time, so handle unknown values gracefully (respond 2xx and ignore).category(string):RUNorSCHEDULE, the group the event belongs to. This is also what you select when routing to a webhook.title(string): a short, human-readable summary. Intended for display; don't parse it.message(string): a longer human-readable detail. Intended for display; don't parse it. Usemetadatafor identifiers.metadata(object ornull): event-specific structured data. See the per-event reference. Alwaysnullon test messages. Treat any keys you don't recognize as opaque; new keys may be added without notice.timestamp(string, ISO 8601, UTC, millisecond precision): when Manta AI created this notification, i.e. when the underlying event happened. It is the same on every retry of the same delivery. It is not the signature timestamp; see Verifying the signature.target(object ornull): reserved, and alwaysnullfor webhook deliveries today. Ignore it.
Notes on the fields:
- Additive changes (new event types, new
metadatakeys) are not considered breaking. Write your receiver to ignore what it doesn't recognize. - Identifiers inside
metadataare UUIDs. The human-friendly IDs you see in the Manta AI UI (such asR-x7Kq2mPafor a run orSCH-…for a schedule) appear only insidemessageandtitletext. Use the UUID inmetadatato look things up via the API.
Event types
Only run and schedule activity is delivered to webhooks. Account, billing, payment, security, team and support events are never sent to external destinations, and neither are in-app or email digests.
Category RUN
RUN_STARTED: a run starts.RUN_SUCCEEDED: a run finishes successfully.RUN_FAILED: a run fails.RUN_TIMEOUT: a run exceeds its maximum execution time.RUN_CANCELLED: a run is cancelled.
Category SCHEDULE
SCHEDULE_PAUSED_NO_CREDITS: a scheduled run was skipped because the organization is out of credits. The schedule is not paused; it retries automatically at its next occurrence. Sent once per out-of-credits streak.SCHEDULE_PAUSED_SUBSCRIPTION: sent after the organization's paid plan becomes active again while schedules are still paused from the earlier lapse, as a reminder that they can be resumed. (See the note under this event below.)SCHEDULE_DISABLED_FAILURES: a scheduled crawl is disabled after repeated consecutive failures.SCHEDULE_RECOVERED: a disabled scheduled crawl is re-enabled because a manual crawl succeeded.
notification.test is also sent (with category: "SUPPORT") by the test buttons. See Sending a test message.
Event reference and examples
All examples show the full body. timestamp values are illustrative.
Run events
RUN_STARTED, RUN_SUCCEEDED, RUN_FAILED, RUN_TIMEOUT and RUN_CANCELLED share a shape:
RUN_STARTED: titleRun Started, messageRun #R-x7Kq2mPa has started.RUN_SUCCEEDED: titleRun Succeeded, messageRun #R-x7Kq2mPa has succeeded.RUN_FAILED: titleRun Failed, messageRun #R-x7Kq2mPa has failed.RUN_TIMEOUT: titleRun Timed Out, messageRun #R-x7Kq2mPa timed out.
metadata has one key:
runId(string, UUID): the run's ID. Use it with the Manta AI API to fetch status, results and artifacts.
{
"type": "RUN_SUCCEEDED",
"category": "RUN",
"title": "Run Succeeded",
"message": "Run #R-x7Kq2mPa has succeeded.",
"metadata": { "runId": "b3f1c2a0-5d7e-4c1a-9e2f-0a1b2c3d4e5f" },
"timestamp": "2026-09-19T14:41:52.310Z",
"target": null
}
- Every run event for the same run has the same
runId. A typical run producesRUN_STARTEDfollowed by exactly one ofRUN_SUCCEEDED,RUN_FAILED,RUN_TIMEOUTorRUN_CANCELLED. - These events fire for runs started from the dashboard, from a schedule, and from CI/CD. CI/CD runs are subject to the API key's notification setting; see What gets sent, and to whom.
RUN_STARTEDand the terminal events are separate deliveries and may arrive out of order (for example if the first one had to be retried). Usetimestampto order them.
SCHEDULE_PAUSED_NO_CREDITS
- Title:
Scheduled run skipped — no credits - Message:
Schedule "<schedule name>" could not run because the organization is out of credits. It will retry automatically once credits are available. metadata.scheduleId(string, UUID): the schedule that was skipped.
{
"type": "SCHEDULE_PAUSED_NO_CREDITS",
"category": "SCHEDULE",
"title": "Scheduled run skipped — no credits",
"message": "Schedule \"Nightly regression\" could not run because the organization is out of credits. It will retry automatically once credits are available.",
"metadata": { "scheduleId": "0c9d2f6e-7a41-4b8e-9d55-3f2a1e6b7c80" },
"timestamp": "2026-09-20T02:00:01.552Z",
"target": null
}
Sent once when the schedule first hits the problem, not on every skipped occurrence. Despite the type name, the schedule stays ACTIVE.
SCHEDULE_PAUSED_SUBSCRIPTION
This type has two shapes in the code, but only one can reach a webhook.
"Paused schedules can be resumed" is sent when the organization's paid plan is active again and one or more schedules are still paused because of the earlier lapse. This is the one you receive.
- Title:
Paused schedules can be resumed - Message:
<n> schedule was paused while your plan was inactive. Resume it whenever you're ready.(plural form:<n> schedules were paused … Resume them whenever you're ready.) metadata.organizationId(string, UUID): the organization whose schedules can be resumed.metadata.pausedCount(integer): the number of schedules currently paused for this reason.
{
"type": "SCHEDULE_PAUSED_SUBSCRIPTION",
"category": "SCHEDULE",
"title": "Paused schedules can be resumed",
"message": "2 schedules were paused while your plan was inactive. Resume them whenever you're ready.",
"metadata": {
"organizationId": "7e1f0a52-33cb-4a6d-8f14-9a0be2c5d611",
"pausedCount": 2
},
"timestamp": "2026-09-21T09:15:44.020Z",
"target": null
}
"Schedule paused" (metadata is { "scheduleId": … }) is raised at the moment a schedule is paused because the plan lapsed. By definition that's when the organization no longer has a paid plan, so webhook routing is unavailable and this shape is not delivered to webhooks. It still appears in-app and by email.
SCHEDULE_DISABLED_FAILURES
- Title:
Scheduled crawl disabled - Message, one of:
Schedule "<name>" was disabled after <n> consecutive failed crawls. Run a crawl manually to re-enable it.Schedule "<name>" was disabled after <n> consecutive failures. Run a crawl manually to re-enable it.(when the failures were in creating the run, before it could start)
metadata.scheduleId(string, UUID): the schedule that was disabled.
{
"type": "SCHEDULE_DISABLED_FAILURES",
"category": "SCHEDULE",
"title": "Scheduled crawl disabled",
"message": "Schedule \"Weekly crawl\" was disabled after 3 consecutive failed crawls. Run a crawl manually to re-enable it.",
"metadata": { "scheduleId": "5a8b3d10-92e4-4c7f-a0d6-1b2c3d4e5f60" },
"timestamp": "2026-09-22T03:10:12.777Z",
"target": null
}
This applies to scheduled crawl schedules only. The number of failures that triggers it is a server-side setting; don't hard-code it.
SCHEDULE_RECOVERED
- Title:
Scheduled crawl re-enabled - Message:
A successful crawl re-enabled schedule "<name>". metadata.scheduleId(string, UUID): the schedule that was re-enabled.
{
"type": "SCHEDULE_RECOVERED",
"category": "SCHEDULE",
"title": "Scheduled crawl re-enabled",
"message": "A successful crawl re-enabled schedule \"Weekly crawl\".",
"metadata": { "scheduleId": "5a8b3d10-92e4-4c7f-a0d6-1b2c3d4e5f60" },
"timestamp": "2026-09-22T11:02:38.041Z",
"target": null
}
This follows a SCHEDULE_DISABLED_FAILURES for the same schedule, once someone runs a successful crawl on that environment.
Quick lookup: metadata by type
RUN_STARTED,RUN_SUCCEEDED,RUN_FAILED,RUN_TIMEOUT,RUN_CANCELLED:{ "runId": "<uuid>" }SCHEDULE_PAUSED_NO_CREDITS:{ "scheduleId": "<uuid>" }SCHEDULE_PAUSED_SUBSCRIPTION:{ "organizationId": "<uuid>", "pausedCount": <integer> }SCHEDULE_DISABLED_FAILURES:{ "scheduleId": "<uuid>" }SCHEDULE_RECOVERED:{ "scheduleId": "<uuid>" }notification.test:null
What gets sent, and to whom
- Organization-wide. Webhook delivery is decided per organization, not per user. An individual member's personal notification preferences (muting runs, digests, email settings) do not affect what your webhook receives.
- Per category. Your integration receives an event if it has an enabled route for that event's category and the integration hasn't been revoked. If several routes match the same event, expect multiple deliveries.
- CI/CD runs. Runs triggered through the CI/CD API are subject to the triggering API key's Notifications setting, which applies to webhooks as well. Off (the default) sends nothing for that key's runs, Failures only sends only
RUN_FAILED,RUN_TIMEOUTandRUN_CANCELLED, and All outcomes sends every run event. - Paid plans only. Delivery requires an active paid subscription. If the organization's plan lapses, delivery pauses; your routes are kept and resume when the plan is active again.
- Always immediate. Webhook routes deliver each event as it happens; they are never batched into a digest. (Only Slack routes can batch.)
- Who triggered it isn't included. Payloads don't identify a user. Use the
runIdto fetch details from the API. - Debouncing. Run and schedule events aren't debounced. Every state change produces a delivery.
Verifying the signature
Every request includes an X-Manta-Signature header:
X-Manta-Signature: t=1758292327,v1=5f8e2a1c9b3d4e6f...
tis the Unix time, in seconds, at which Manta AI made this delivery attempt. It is regenerated on every attempt, so on a retry it differs from the previous attempt'st.v1is an HMAC-SHA256 signature, hex-encoded, computed over the string${t}.${rawRequestBody}.- The HMAC key is your signing secret string exactly as displayed (64 hexadecimal characters). Use the text itself as the key; do not hex-decode it into bytes first.
- Future signature versions may add more
vN=pairs to the header. Read the pair namedv1by name rather than by position.
t is not the body's timestamp. The body timestamp is when the event was created and is identical across retries. t is when this particular attempt was sent. For a first-attempt delivery they are typically within a fraction of a second, but they are computed separately and are not equal, and after a retry they can be minutes apart. Use t for replay protection and the body timestamp for the event's actual time.
Always verify this signature before trusting a request. Anyone who knows your endpoint URL can otherwise send fake payloads to it. Also reject requests whose t is too far from your clock (5 minutes is a common tolerance) so a captured request can't be replayed later. Because t is refreshed on every attempt, legitimate retries always pass this check.
Node.js example
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300;
function verifyMantaSignature(rawBody, signatureHeader, signingSecret) {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
);
const { t: timestamp, v1: signature } = parts;
if (!timestamp || !signature) return false;
// Reject stale (or far-future) requests to prevent replay.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac('sha256', signingSecret) // the secret string as-is, not hex-decoded
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison to avoid timing attacks.
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Express example — use a raw body parser for this route so `rawBody`
// is the exact bytes Manta AI signed, not a re-serialized JSON object.
app.post(
'/webhooks/manta',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
const isValid = verifyMantaSignature(
rawBody,
req.header('X-Manta-Signature'),
process.env.MANTA_WEBHOOK_SIGNING_SECRET,
);
if (!isValid) return res.status(401).send('Invalid signature');
const payload = JSON.parse(rawBody);
// ... handle payload ...
res.status(200).send('OK');
},
);
Python example
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_manta_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
if not signature_header:
return False
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
return False
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(signing_secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
Important: compute the signature over the exact raw request body bytes, before any JSON parsing or re-serialization. Re-stringifying a parsed object can produce a different byte sequence (key order, whitespace) and fail verification even though the data is identical.
Responding to a delivery
- Success: any
2xxstatus. The response body and headers are ignored. - Failure: any other status (including
4xxand5xx), a timeout (over 10 seconds), a connection error, a TLS error, or aBlocked: …address-validation failure. All failures are retried the same way; a4xxis not treated as permanent. - Respond with
2xxas soon as you've verified the signature and durably queued the work. Don't do slow processing inline. - Return
2xxfor event types you don't recognize. Otherwise those events will be retried needlessly and eventually marked dead.
Retries and delivery guarantees
If your endpoint doesn't respond with a 2xx status (or times out), Manta AI retries with exponential backoff:
- First attempt.
- About 1 minute after the previous failure.
- About 2 minutes after the previous failure.
- About 4 minutes after the previous failure.
- About 8 minutes after the previous failure.
- Delays are minimums, measured from the previous failure. Deliveries are picked up by a worker that runs every 10 seconds, so an attempt can start up to about 10 seconds later than shown, and longer if the queue is busy.
- After 5 failed attempts, the delivery is marked dead and not retried further. There's no manual replay today. Every attempt, and its error, is visible in the Delivery log tab.
- If the integration is revoked (deleted) while a delivery is waiting to be retried, that delivery is marked dead without another attempt.
- Each retry re-sends the same body with a fresh
X-Manta-Signatureheader (newt, newv1).
What this means for your receiver:
- At-least-once delivery. A retry can occur after your endpoint actually succeeded but the response was lost in transit, and an event can occasionally be delivered more than once (for example, when it matches more than one route). Design your endpoint to be idempotent.
- No event ID. Payloads don't carry a unique delivery or event ID today. To de-duplicate, key on
typeplus the identifyingmetadatavalue plustimestamp(for exampleRUN_FAILEDplusrunId, which is naturally unique per run). - No ordering guarantee. A delivery that's being retried can arrive after a later event. Use
timestampto order events, or fetch the current state from the API instead of relying on arrival order.
Sending a test message
There are two test entry points in the UI. Both send a real, signed request to your endpoint with this body:
{
"type": "notification.test",
"category": "SUPPORT",
"title": "Test notification",
"message": "This is a test message from Manta AI confirming the \"<integration name>\" webhook is configured correctly.",
"metadata": null,
"timestamp": "2026-09-19T14:30:00.000Z",
"target": null
}
This is the same body shape used for real events. Only type, title and message say it's a test, and metadata is null.
- Test connection runs while you're adding the integration, before you save. The integration name in
messageisConnection test. It's signed with a one-off throwaway secret that is never shown to you. - Send test runs on a saved integration. The integration name in
messageis the integration's own name. It's signed with your integration's real signing secret.
Things to know:
- Because the pre-save connection test uses a throwaway secret, its signature can't be verified with your real signing secret. Use it to confirm the URL is reachable and returns 2xx. To test signature verification end to end, save the integration and use Send test.
- Tests are sent immediately, not through the retry queue. You get a single attempt, and the result (success, HTTP status or error) is shown right away. Test sends don't retry.
- A test message counts as a request like any other: it must pass the same HTTPS and public-address checks.
Managing your signing secret
- The secret is a 64-character hexadecimal string, generated when you connect the integration and shown once. It can't be retrieved again, so store it in your secrets manager immediately.
- To rotate it, revoke and recreate the integration; there's currently no in-place rotation. Because the new integration has a new secret, update your receiver at the same time. Requests signed with the old secret stop being sent once the old integration is revoked, and any of its deliveries still waiting for a retry are dropped.
- Each integration has its own secret. Secrets aren't shared between integrations or organizations.
Troubleshooting
- No deliveries at all. The organization is not on a paid plan; the integration has no enabled route for the event's category; the integration was revoked; or, for CI/CD-triggered runs, the API key's Notifications setting is Off.
- Signature never matches. The body was parsed and re-serialized before hashing (use the raw bytes); the secret was hex-decoded (use the string as-is); you compared against the body
timestampinstead of the headert; or you used the wrong integration's secret. - Signature matches on the first delivery but not on retries. You're caching an old
tor signature. Each attempt has a newtandv1, so verify every request independently. - Everything is rejected as stale. Your server clock is off by more than the tolerance you configured. Sync it with NTP.
- Test message fails with
Blocked: …. The URL isn'thttps://, contains credentials, or resolves to a private or reserved address. Timed out after 10000ms.Your endpoint took more than 10 seconds. Acknowledge immediately and process asynchronously.Endpoint responded with HTTP <n>.Your endpoint returned a non-2xx status. That's the error you'll see on a test result; real deliveries are retried.- Duplicate events. Expected occasionally under at-least-once delivery. De-duplicate as described under Retries and delivery guarantees.
Try it on your own app
Point Manta at a URL and see what it finds — no scripts, no setup. Free, no credit card.