> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mymarky.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a signed POST when a post publishes, and verify it came from Marky.

Register an HTTPS URL with [`POST /api/webhooks`](https://docs.mymarky.ai/api-reference/create-webhook) and
we send it a `POST` every time a post publishes. Save the `secret` from the response:
it is shown once and is what you verify deliveries with.

## What we send

```http theme={null}
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Marky-Event: post.published
X-Marky-Timestamp: 1767225600
X-Marky-Signature: sha256=5d41402abc4b2a76b9719d911017c592...

{"event": "post.published", "post_id": "...", "business_id": "...", "caption": "...",
 "media_urls": ["..."], "publish_to": ["instagram"], "published_at": "2026-01-01T00:00:00+00:00"}
```

Answer with any `2xx` within 10 seconds. Anything else (or no answer) is retried up to
two more times, 2 and 4 seconds apart. Redirects are not followed. Every attempt is
listed by [`GET /api/webhooks/{webhook_id}/deliveries`](https://docs.mymarky.ai/api-reference/list-webhook-deliveries).

## Verify the signature

`X-Marky-Signature` is `sha256=` followed by the hex HMAC-SHA256, keyed with your
webhook `secret`, of:

```
<X-Marky-Timestamp> + "." + <raw request body>
```

Two rules make this safe:

1. **Use the raw body bytes.** HMAC the body exactly as received, before any JSON
   parsing. Re-serializing the JSON changes the bytes and the check fails.
2. **Reject old timestamps.** The timestamp is part of what is signed, so it cannot be
   altered. Refuse anything more than 5 minutes from your clock, so a captured
   delivery cannot be replayed later. Each retry is signed with a fresh timestamp.

Compare signatures in constant time.

```python Python theme={null}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_marky_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
    if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# Flask example
# @app.post("/webhook")
# def webhook():
#     ok = verify_marky_webhook(
#         SECRET,
#         request.get_data(),  # raw bytes, not request.json
#         request.headers["X-Marky-Timestamp"],
#         request.headers["X-Marky-Signature"],
#     )
#     if not ok:
#         abort(400)
```

```javascript Node.js theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyMarkyWebhook(secret, rawBody, timestamp, signature) {
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.`)
      .update(rawBody) // a Buffer of the raw body, e.g. express.raw({ type: "application/json" })
      .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Which URLs are allowed

The URL must use `https://` and its hostname must resolve only to public internet
addresses. Private networks (`10.x`, `192.168.x`, `172.16-31.x`), loopback, link-local
and cloud-metadata addresses are refused with `400` when you register. We resolve the
hostname again on every delivery and refuse to connect if it has since moved to a
private address; that attempt is logged as blocked and not retried.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.