Scheduled delivery webhook
When a scheduled post becomes due, AI Content Studio sends a signed JSON payload to the delivery endpoint you configure for each brand. This page describes exactly what we send and how to verify it.
POSThttps://your-domain.example.com/webhooks/social-wewebAny HTTPS URL you control- HTTP method
- POST
- Content type
- application/json
- Timeout
- 10 seconds
- Max attempts
- 5
Getting started
Configure a delivery endpoint per brand from the calendar screen. Each brand stores its own HTTPS URL and signing secret, and delivery only runs while the endpoint is enabled.
- 1Open the calendar and choose delivery settings for a brand.
- 2Enter the HTTPS URL of your receiver and a signing secret you generate yourself.
- 3Enable delivery and send a test event to confirm your receiver answers with a 2xx status.
Request headers
Every request carries these headers. Use the signature header to authenticate the request and the idempotency key to deduplicate retries.
| Header | Example | Description |
|---|---|---|
content-type | application/json | Always JSON encoded UTF-8. |
user-agent | Social-WeWeb-Scheduled-Delivery/1.0 | Identifies the scheduler that sent the request. |
x-social-weweb-event | post.scheduled | The event name, post.scheduled or delivery.test. Branch on this header, not the body. |
x-social-weweb-signature | sha256=9f86d0818884… | HMAC SHA-256 of the raw request body, keyed with your signing secret and hex encoded. |
idempotency-key | 0f6d5a1c-2f1b-4f0e-9f6d-2b7a1c8e4d33 | Stable per delivery. Retries of the same post reuse the same key, so treat repeats as duplicates. |
Verifying the signature
Compute an HMAC SHA-256 over the raw request body using your signing secret, hex encode it, and compare it with the value after the sha256= prefix. Always compare in constant time and always hash the raw bytes before any JSON parsing or reformatting.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: Buffer, header: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const received = header.replace(/^sha256=/, "");
if (received.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}Reject any request whose signature does not match. A mismatch means the body was altered in transit or the sender does not hold your secret.
Events
Two event names are sent to the same endpoint. Branch on the x-social-weweb-event header so test traffic never reaches your production publishing path.
| Event | Sent when | Notes |
|---|---|---|
post.scheduled | A post reaches its scheduled time and delivery is enabled for its brand. | Carries the real post. A successful response marks the post as published. |
delivery.test | You press the test button in delivery settings. | Carries placeholder content. Post and page identifiers are null. |
Payload fields
The body is a single JSON object. Fields are always present; nullable fields are sent as null rather than omitted.
| Field | Type | Description |
|---|---|---|
| Top level | ||
post_id | string | null | Identifier of the generated post. Null for test events. |
title | string | Short headline for the post. |
content | string | Main body copy for the post. |
hashtags | string[] | Hashtags for the post, without the leading hash character. |
image_url | string | null | Absolute URL of the selected image. Null when no image was selected. |
published_at | string | null | ISO 8601 timestamp the post was scheduled for. Null for test events. |
channel | string | Social network of the page, such as facebook or line. Test events send test. |
target_page | string | Display name of the social page. |
Example payloads
Sample bodies for both events, formatted for readability. The actual request body is minified.
post.scheduled
{
"post_id": "d41a6e77-0b2c-4a19-bb5e-6f8c4d2a9013",
"title": "New cold brew, out today",
"content": "Slow steeped for 18 hours and served over clear ice.",
"hashtags": ["coldbrew", "bangkokcafe"],
"image_url": "https://cdn.example.com/media/cold-brew.jpg",
"published_at": "2026-08-05T09:00:00.000Z",
"channel": "facebook",
"target_page": "Nara Cafe Bangkok"
}delivery.test
{
"post_id": null,
"title": "Scheduled delivery test",
"content": "This payload verifies the brand delivery destination.",
"hashtags": [],
"image_url": null,
"published_at": null,
"channel": "test",
"target_page": "Test page"
}Expected response
Answer with any 2xx status as soon as you have persisted the payload. Do your publishing work asynchronously: the request is aborted after 10 seconds and any non-2xx status counts as a failure.
- Any 2xx status is treated as delivered, and the post moves to published.
- Any other status, a timeout, or a connection error is treated as failed and queued for retry.
- Redirects are not followed. Publish your receiver at its final URL.
- The response body is ignored, so an empty 200 is fine.
Retries and idempotency
Failed deliveries are retried with a fixed backoff. After the fifth attempt the delivery is marked failed and is not retried again.
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 1m |
| 3 | 5m |
| 4 | 15m |
| 5 | 60m |
Every attempt for the same post reuses the same idempotency key, so store that key and ignore a payload you have already processed.
Endpoint requirements
Delivery URLs are validated when saved and again before every send.
- The URL must use HTTPS.
- The hostname must resolve only to public IP addresses. Loopback, private and link-local ranges are rejected.
- The URL must not embed a username or password. Authenticate with the signature instead.
- A signing secret is required before delivery can be enabled.