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 same id is 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.typeuser, api_key, agent, or system; actor.id may be null. 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, and durationMs; 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 on id and 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 failed with a blocked: <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.