Webhooks
Webhooks
Signed, durable HTTP delivery of project events with per-delivery inspection and manual retry.
Webhooks push project events — entry lifecycle, change set creation and publishing, asset changes, schema deployments, and report activity — to your HTTPS endpoints as signed JSON POSTs. Events are stored immutably, deliveries are durable background jobs with exponential backoff, and every delivery is inspectable and manually retryable. The emitted event types are listed in the event reference.
Managing endpoints
Endpoints are managed per project and require the webhooks:manage scope:
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/projects/{project}/webhooks |
List endpoints |
POST |
/v1/projects/{project}/webhooks |
Create an endpoint (url, events[], enabled) |
PATCH |
/v1/projects/{project}/webhooks/{webhook} |
Update url, events, or enabled |
DELETE |
/v1/projects/{project}/webhooks/{webhook} |
Delete an endpoint |
GET |
/v1/projects/{project}/webhooks/{webhook}/deliveries |
Last 100 deliveries with status, response code, duration |
POST |
/v1/projects/{project}/webhooks/{webhook}/deliveries/{delivery}/retry |
Re-enqueue one delivery immediately |
Creating an endpoint returns a signing secret of the form whsec_... exactly once — store it; it is kept encrypted at rest and never shown again. The same operations are available via the management client and CLI.
$ curl -X POST https://api.myna.sh/v1/projects/my-site/webhooks \
-H "Authorization: Bearer $MYNA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/myna", "events": ["entry.published", "change_set.published"]}'
# → { "data": { "endpoint": { ... }, "signingSecret": "whsec_..." } }
The event envelope
Each delivery POSTs a JSON body:
{
"id": "whe_...",
"type": "entry.published",
"timestamp": "2026-07-23T12:00:00.000Z",
"project": "prj_...",
"actor": { "type": "api_key", "id": "key_..." },
"data": { "...": "event-specific payload" }
}
id— unique event id; the sameidis redelivered on every retry, so use it as your idempotency key.timestamp— when the event was created (ISO 8601), not when this delivery attempt was made.actor.type—user,api_key,agent, orsystem;actor.idmay benull. This carries the same attribution recorded on revisions.data— event-specific payload; see the event reference.
Custom headers
An endpoint can carry extra request headers, which is what lets a webhook reach a receiver that requires authentication — CI, a deploy hook, an API gateway:
$ curl -X POST https://api.myna.sh/v1/projects/my-site/webhooks \
-H "Authorization: Bearer myna_sk_..." -H "Content-Type: application/json" \
-d '{
"url": "https://api.github.com/repos/me/site/dispatches",
"events": ["change_set.published"],
"headers": {
"Authorization": "Bearer ghp_...",
"Accept": "application/vnd.github+json"
}
}'
Header values are treated as credentials: stored encrypted and never returned,
the same as the signing secret. Reading an endpoint gives you headerNames so you
can see what is configured without exposing it. PATCH replaces the whole set;
{} clears it. Up to 10 headers, names limited to letters, digits, and hyphens.
Myna sets Content-Type, User-Agent, and the three myna-webhook-* headers
itself, and refuses a custom value for any of them — a stored value can never
displace the signature you verify with.
Body templates
Some receivers demand a body of their own shape. GitHub's repository_dispatch
requires an event_type key and ignores everything else, so Myna's envelope
alone gets a 422. Set bodyTemplate to send exactly what it expects:
{
"url": "https://api.github.com/repos/me/site/dispatches",
"events": ["change_set.published"],
"headers": { "Authorization": "Bearer ghp_...", "Accept": "application/vnd.github+json" },
"bodyTemplate": "{\"event_type\":\"changelog-published\"}"
}
{{type}}, {{id}}, and {{project}} are substituted from the event. The
template must be valid JSON once substituted, which is checked when you save it
rather than on every delivery. Set it to null to go back to the envelope.
The signature is always computed over the body actually sent, so a template never produces something the receiver cannot verify.
myna.sh uses exactly this: publishing a changelog entry fires
change_set.published at a GitHub repository_dispatch, which rebuilds the site.
Verifying signatures
Every request carries three headers:
| Header | Value |
|---|---|
myna-webhook-id |
The event id (same across retries) |
myna-webhook-timestamp |
Unix seconds at the time of this delivery attempt |
myna-webhook-signature |
t=<timestamp>,v1=<hex> |
The signature is HMAC-SHA256, hex-encoded, over the string <timestamp>.<rawBody> — the value of myna-webhook-timestamp, a literal ., then the exact raw request body — keyed with your whsec_... secret. Requests also arrive with User-Agent: Myna-Webhooks/1 and Content-Type: application/json.
Verify against the raw bytes (before any JSON parsing), compare in constant time, and reject stale timestamps to prevent replay — a tolerance of 5 minutes is a sensible window, since retries always re-sign with a fresh timestamp:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyMynaWebhook(
secret: string, // whsec_...
rawBody: string, // exact request body, unparsed
headers: Record<string, string | undefined>,
toleranceSeconds = 300,
): boolean {
const timestamp = headers["myna-webhook-timestamp"];
const signature = headers["myna-webhook-signature"];
if (!timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false; // replay protection
const v1 = signature
.split(",")
.find((p) => p.startsWith("v1="))
?.slice(3);
if (!v1) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
Delivery semantics
- Durable jobs. Emitting an event stores an immutable envelope and enqueues one delivery job per subscribed, enabled endpoint. Deliveries survive restarts.
- Success is any 2xx response. Anything else — non-2xx, connection failure, or exceeding the 10-second timeout — schedules a retry.
- Backoff. Up to 8 attempts with delays of 30s, 2m, 10m, 30m, 1h, 3h, 6h, and 12h — roughly 24 hours of retries — after which the delivery is marked
failed. - Inspection and manual retry. The deliveries endpoint exposes per-delivery
status(pending,delivered,failed),attempt,responseStatus, anddurationMs; the retry endpoint re-enqueues any delivery immediately, including exhausted ones. - Idempotency. Retries and manual redelivery send the same envelope with the same event
id. Deduplicate onidand respond 2xx quickly; do heavy work asynchronously. - Ordering is not guaranteed across events. Use
timestamp(event creation time) to order, or re-fetch current state on receipt.
Security notes
- HTTPS/HTTP only; other schemes are rejected. Redirects are never followed — a redirect response counts as a failure.
- Private targets are rejected. The endpoint host is re-resolved via DNS at each delivery, and loopback, private (10/8, 172.16/12, 192.168/16), link-local, CGNAT, ULA, and multicast/reserved addresses are blocked. A delivery to a blocked target is marked
failedwith ablocked: <reason>note and is not retried. - Response bodies are truncated to 2,048 bytes when stored for inspection — do not echo sensitive data back from your handler.
- The signing secret is stored encrypted; rotate it by deleting and recreating the endpoint.
For the exact payloads of each event type — including which declared types are emitted today — see the event reference.
