Quick start
- Open Dashboard → Notifications → HTTPS webhook.
- Enter a public HTTPS endpoint you control.
- Choose AI-matched posts only or All collected posts.
- Leave request signing enabled, save the webhook, and copy the signing secret when it appears.
- Select Send test and return any HTTP
2xxresponse.
Relay-only processing
If the account selects Relay only — no AI in Dashboard → Settings, AI evaluation stops and every newly collected post is delivered as post.collected through an enabled webhook, regardless of the webhook's saved match filter.
Preserve the raw body
Signature verification must use the exact request bytes before JSON parsing. Parsing and re-serializing JSON changes the signed value.
Request headers
| Header | Value | When sent |
|---|---|---|
Content-Type | application/json | Always |
User-Agent | Narrative-Field-Webhook/1.0 | Always |
X-Narrative-Field-Event | Event type, such as post.matched | Always |
X-Narrative-Field-Event-Id | Stable event identifier | Always |
X-Narrative-Field-Timestamp | Unix timestamp in seconds | When signing is enabled |
X-Narrative-Field-Signature | sha256=<hex digest> | When signing is enabled |
Authorization | The exact value configured in the dashboard | When configured |
Payloads and events
All requests use POST. Fields whose source value is unavailable are sent as null. ISO timestamps are UTC.
post.matched
Sent when a post matches an active monitoring prompt. If selected-prompt filtering is enabled, only the chosen prompts produce deliveries.
{
"event": "post.matched",
"event_id": "mention:example-event-id",
"group_url": "https://www.facebook.com/groups/example/",
"group_name": "Example monitored group",
"post_url": "https://www.facebook.com/groups/example/posts/123456789/",
"post_id": "123456789",
"author": "Example member",
"author_profile_url": "https://www.facebook.com/groups/example/user/100000000000001/",
"post_text": "Looking for a local supplier this week.",
"image_urls": [
"https://media.example.com/facebook-posts/abc123.jpg"
],
"published_time": "2026-09-06T08:10:00.000Z",
"collected_time": "2026-09-06T08:12:04.000Z",
"match": {
"prompt_id": "prompt-id",
"prompt": "Find posts asking for a local supplier"
}
}
post.collected
Sent for every collected post from the account's approved groups when delivery mode is All collected posts. It uses the same top-level post fields and does not include match.
{
"event": "post.collected",
"event_id": "collected_post:account-id:12345",
"group_url": "https://www.facebook.com/groups/example/",
"group_name": "Example monitored group",
"post_url": "https://www.facebook.com/groups/example/posts/123456789/",
"post_id": "123456789",
"author": "Example member",
"author_profile_url": null,
"post_text": "A newly collected post.",
"image_urls": [],
"published_time": "2026-09-06T08:10:00.000Z",
"collected_time": "2026-09-06T08:12:04.000Z"
}
image_urls
Every post event includes image_urls as an array. It is empty when the post has no captured image. For newly collected attachments, URLs point to our durable media archive rather than Facebook's temporary CDN links. Preserve the array because a post can contain more than one image.
webhook.test
Sent once when Send test is selected. Its post fields contain clearly labeled example values, including image_urls: []. A test event does not include match.
Verify signatures
When signing is enabled, calculate HMAC-SHA256 over:
<X-Narrative-Field-Timestamp>.<raw request body>
Encode the digest as lowercase hexadecimal and compare it to the value after sha256= using a constant-time comparison. Reject stale timestamps to limit replay attacks.
Node.js and Express
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/narrative-field", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("x-narrative-field-timestamp") || "";
const supplied = req.get("x-narrative-field-signature") || "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || age > 300) return res.sendStatus(401);
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.NARRATIVE_FIELD_WEBHOOK_SECRET)
.update(timestamp + ".")
.update(req.body)
.digest("hex");
const valid = supplied.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate and enqueue work using event.event_id.
res.sendStatus(204);
});
Responses, retries, and idempotency
- Return any
2xxresponse within 12 seconds to acknowledge delivery. - Redirects are not followed and are treated as failures.
- Production events are attempted up to 8 times.
- Retry delay starts at 30 seconds, doubles after each failure, and is capped at one hour.
- The same
event_idis retained across retries. Store it and ignore an event already processed. - Ordering is not guaranteed. Acknowledge quickly and process asynchronously.
- If supplied, the receiver's
X-Request-IDresponse header is recorded with the delivery.
Endpoint and security requirements
- The endpoint must use HTTPS and resolve to a public internet address.
- Localhost, private-network, link-local, documentation-only, and reserved IP destinations are rejected.
- Do not put credentials in the endpoint URL. Use the optional
Authorizationvalue instead. - The endpoint and Authorization value are encrypted at rest.
- The signing secret is displayed only when created or rotated. Rotating it immediately invalidates the previous secret.
