Skip to content
Browse the docs

Docs / Developers

Webhooks and REST hooks

Be told the moment work finishes: events, signed payloads, retries, REST hook subscriptions, and Slack and Discord formats.

A webhook tells another system the moment something happens in your workspace: an episode is ready, held or published, a job failed, credits are running low. ChannelForge sends a POST to an address you choose — your own endpoint with a signed JSON body, or a Slack or Discord channel with a short readable message — and keeps a log of every delivery, with retries when your side is down.

Two ways to subscribe#

Added in the appREST hook
Made byan owner or admin, in Settings → API & webhookscode, with POST /api/v1/webhooks and an API key of any scope
Formatssigned JSON, Slack or Discordsigned JSON
Livesuntil it is deleted in the appas long as the key that made it; removed by DELETE /api/v1/webhooks/{id}, or switched off when it answers 410 Gone
How manythe app adds one while the workspace has fewer than 10 endpoints in all, REST hooks includedup to 50 made through the API
Used byyour own services, team chatZapier, Make, n8n and your integrations, which subscribe when they are switched on

Both carry the same events with the same bodies and signatures, and both appear in the same list in the app. Webhooks are part of the API, so they are delivered only while the workspace's plan includes it (Pro and above); events are recorded in the workspace's event feed either way.

Add an endpoint in the app#

  1. Open Settings → API & webhooks and go to Webhooks (owners and admins).
  2. In New endpoint, enter the Endpoint URL — a public https:// address.
  3. Choose the Format: Signed JSON for your own code, Slack for an incoming-webhook address (https://hooks.slack.com/…), Discord for a channel webhook address (https://discord.com/api/webhooks/…).
  4. Under Events, untick what you do not want (every event offered is ticked).
  5. Press Add endpoint. The address is checked first ("Checking the address…").
  6. For signed JSON, copy the Signing secret (whsec_…): it is shown only now. Slack and Discord need none.

Each endpoint in the list shows its address (a Slack or Discord address masked, since the address is its key), its events, its status — On, Failing (N) or Paused — and its last delivery with the answer your side gave. Deliveries opens its log, Pause and Turn on stop and restart it, Delete (pressed twice) removes it.

Events#

EventWhen it is sent
episode.readyAn episode passed the quality critic and is in the library, ready to post (or to approve, where its channel asks for approval).
episode.heldAn episode was held back and will not post: the critic did not pass it after its fix rounds (by: "qa", not charged), or a member or a reviewer held it (by: "member" or "review"). reason says why.
episode.publishedChannelForge posted an episode to a platform: which platform, and the link of the post.
post.publishedThe same moment as episode.published, in the original shape. Kept for endpoints that already use it.
post.failedPosting an episode to a platform failed after its retries; error says what went wrong, in ChannelForge's words.
job.succeededA job finished: its kind, its ids, its charge and what it made (for an episode job, the episodes).
job.failedA job failed after its last retry; result.error says why. Failures on our side are not charged.
credits.lowThe available credits fell below the low mark (about one standard episode). At most once a day.
film.outline_readyA film's outline waits for an approval before it is made.
film.readyA film reached the 8/10 quality floor and is ready to approve and post.
film.heldA film stayed under the floor after its rounds: held for you to look at, not charged.
film.failedA film could not be finished. Failures on our side are not charged.
film.cancelledA film was cancelled before it was ready, by a member or because its outline was not approved in time.
film.publishedChannelForge posted a film to a platform, with the link of the post.
film.post.publishedThe earlier name of film.published, with the same body.
film.post.failedPosting a film to a platform failed after its retries.
breakout.detectedA post ChannelForge made is far above its channel's usual at the same age (24–72 hours), and whether follow-ups were planned.
coach.readyThe weekly coach brief is ready: its week, how many recommendations, its link.
outlier.foundAn account a channel watches on the outlier radar has an upload far above that account's own usual for its age.
topics.from_commentsThis week's themes of a channel's comments — what its viewers asked for — and whether their topics were planned.
originality.heldA new episode read as a near-duplicate of an earlier one, so it was held and not charged.
storyboard.readyA storyboard waits for an approval before its episode is made; until expires_at, then it is approved by itself or the day's episode is skipped, as the channel chose.
episode.revisedA revision of an episode passed every check and the critic, and is now its current version.

The film events are offered where the workspace has long-form films, and the breakout, coach, radar, comment-theme, originality, storyboard and revision events where those features are on. GET /api/v1/webhooks/events lists exactly the events your workspace can subscribe to, each with an example body. Send a test event (below) sends one more type, webhook.test, to a single endpoint.

What a delivery looks like#

Every delivery to a signed JSON endpoint is a POST with Content-Type: application/json and this body:

json
{
  "id": "evt_01jbd3m2n5b8v1c4x7z0a3s6d9",
  "type": "episode.ready",
  "created": 1793642404,
  "data": {
    "episode": { "episode_id": "ep_01jbd3k0q8z1x6m4c2v7s9n5ty", "title": "Why the Golden Gate sways", "critic": 8, "state": "ready", "reason": null },
    "channel_id": "ch_01jb9w3m0r8e2t5y7u1i4o6p3a",
    "app_url": "https://forge.storeswiki.com/w/northwind/episodes/ep_01jbd3k0q8z1x6m4c2v7s9n5ty"
  }
}

id is the event's own id, the same on every retry; created is the moment it happened, in Unix seconds. The headers:

HeaderWhat it carries
X-Forge-Eventthe event type, e.g. episode.ready
X-Forge-Event-Idthe event's id — the same on every retry: de-duplicate on it
X-Forge-Deliverythis delivery's id (a resend is a new delivery of the same event)
X-Forge-Attempt1 to 8
X-Forge-Timestampwhen this attempt was signed, in Unix seconds
X-Forge-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">

A few more bodies, as they arrive in data:

json
{ "episode": { "episode_id": "ep_…", "title": "Why the Golden Gate sways" }, "channel_id": "ch_…",
  "platform": "instagram", "url": "https://www.instagram.com/reel/…", "posted_at": "2026-11-02T18:00:04.512Z" }

That is episode.published. job.failed:

json
{ "job": { "id": "job_…", "kind": "kit.create", "state": "failed", "channel_id": "ch_…", "episode_id": null, "project_id": null, "credits_charged": 0 },
  "result": { "error": "the kit did not pass its checks — not charged" } }

And credits.low:

json
{ "credits": { "available": 148, "threshold": 200 }, "workspace": { "id": "ws_…", "slug": "northwind" },
  "app_url": "https://forge.storeswiki.com/w/northwind/settings/billing" }

Verify the signature#

Check every delivery before you act on it. Take the timestamp and the signature from X-Forge-Signature, compute the HMAC-SHA256 of the timestamp, a dot and the raw body — the exact bytes received, before any JSON parsing — keyed with the endpoint's whole signing secret (with its whsec_), compare the two in constant time, and refuse a timestamp more than five minutes away, so that a captured delivery cannot be replayed later.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body exactly as received, before JSON.parse
export function verifyForgeSignature(header, rawBody, secret, toleranceS = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceS) return false; // stale: a replay
  const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return typeof parts.v1 === 'string' && parts.v1.length === want.length
    && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(want));
}
python
import hashlib, hmac, time

def verify_forge_signature(header: str, raw_body: bytes, secret: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance_s:
        return False  # stale: a replay
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))

To test your code, this body, signed at t = 1793642404 with the secret whsec_vector_secret, must give the signature below (run the check with that t as "now"):

body:      {"id":"evt_01jbd3m2n5b8v1c4x7z0a3s6d9","type":"episode.ready","created":1793642404,"data":{"episode":{"episode_id":"ep_1"}}}
signature: t=1793642404,v1=bdd670d0c560b3b666f3f503997bf3358df674b21c93e9574c1a5a64e0d51a7e

Retries and de-duplication#

  • Answer with any 2xx within 10 seconds. Do slow work after you have answered.
  • Anything else — an error status, a timeout, a refused connection — is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, each a little later at random so a recovering endpoint is not hit by every retry at once: 8 tries over about two days. Then the delivery has failed for good.
  • Every try is signed again with a fresh timestamp; the body and its id never change. A receiver that was down for an hour therefore still accepts the retries.
  • De-duplicate on the event id (or X-Forge-Event-Id): a delivery can arrive more than once, for example when your answer was lost or someone pressed Resend.
  • Deliveries are not guaranteed to arrive in order — a retry arrives after newer events. Use created to order them.
  • An endpoint whose deliveries fail for good 20 times in a row is switched off (it shows Paused); any delivery that gets through resets the count. Turn on starts it again.
  • Events and their deliveries are kept 30 days.

The delivery log#

Deliveries, beside each endpoint in Settings → API & webhooks, shows every event sent to it in the last 30 days: how many were delivered, how many wait to be retried, how many failed for good, and whether the endpoint is on. Latest deliveries lists the newest 50 — when, the event, its state, how many tries, what your endpoint answered — and What was sent shows the exact body.

  • Send a test event sends webhook.test to this endpoint alone, within about 30 seconds, signed like every delivery: the way to check that your endpoint answers and that it checks the signature. Its data is {"message": "A test event from ChannelForge: this endpoint is connected.", "workspace": {"id", "slug"}}.
  • Resend sends an event again as a new delivery (the old one stays in the log); on a delivery that is still waiting, Try now sends it without waiting for its next retry.

GET /api/v1/webhooks/{id} gives the same: the endpoint and its latest 25 deliveries, each with its state (pending, retrying, delivered, failed, skipped or cancelled), tries, last status and error, and its next try.

REST hooks#

Integrations that switch triggers on and off — Zapier, Make, n8n, your own — subscribe through the API, with a key of any scope:

bash
curl -s -X POST https://forge.storeswiki.com/api/v1/webhooks \
  -H "Authorization: Bearer fk_live_…" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.zapier.com/hooks/standard/…", "events": ["episode.ready"], "label": "Zapier: New Episode Ready"}'
# 201 { "id": "whk_…", "events": ["episode.ready"], "created_via": "api", "secret": "whsec_…", … }

curl -s -X DELETE https://forge.storeswiki.com/api/v1/webhooks/whk_… -H "Authorization: Bearer fk_live_…"
# { "id": "whk_…", "deleted": true }
  • url is a public https:// address (500 characters at most); events is 1 to 30 event types from GET /api/v1/webhooks/events; label (80 characters) says where it comes from, and shows in the app.
  • The signing secret is in the 201 answer only.
  • A subscription lives as long as its key: once the key is revoked or expires, nothing more is sent to it.
  • An address that answers 410 Gone is switched off at once — what a deleted Zap or scenario answers.
  • DELETE removes only subscriptions made through the API (403 made_in_app for an endpoint added in the app).
  • GET /api/v1/webhooks lists every endpoint of the workspace, never with its secret.

Slack and Discord#

An endpoint with the Slack or Discord format gets one short message per event in that app's own format, instead of the signed JSON: a title, a line of detail and a link that opens the episode, job or page in ChannelForge. Slack's is a text message with the link at the end; Discord's an embed, sent as "ChannelForge" with mentions turned off. These deliveries carry no signature — Slack and Discord do not check one — so the address itself is the secret: keep it private, and it is always shown masked.

Messages are written for these events: episode.ready, episode.held, episode.revised, storyboard.ready, post.published, post.failed, job.succeeded (but not for an episode or a post, which have their own), job.failed, credits.low, breakout.detected, coach.ready, outlier.found, topics.from_comments, originality.held and the test event. Other events sent to a chat endpoint are marked skipped in its log — subscribe a signed JSON endpoint for the film events and episode.published.

The event feed#

GET /api/v1/events returns the workspace's latest events, newest first, exactly as webhooks deliver them — for polling, for catching up after your receiver was down, and for the sample data automation tools show while you set a trigger up.

bash
curl -s "https://forge.storeswiki.com/api/v1/events?type=episode.ready&limit=10" -H "Authorization: Bearer fk_live_…"

type is one event type, limit 1 to 50 (default 10), cursor the next_cursor of the previous page. The feed keeps 30 days.

Addresses we deliver to#

  • https:// only, with no user name or password in the address (check the signature instead).
  • Public internet addresses only. Private, loopback, link-local and other reserved addresses are refused, both as written and as the host name resolves — when you add the endpoint, and again at every delivery. The connection then goes to the address that was checked, so a DNS change cannot redirect it, and the certificate is verified for the host name.
  • Redirects are never followed: answer from the address itself.
  • A Slack address must use the Slack format and a Discord address the Discord format; signed JSON to them would be refused by those apps.

What payloads never carry#

Payloads are ChannelForge's own data: ids, titles, states, credits, the critic's verdict, schedules and the link of a post ChannelForge made — never a platform's views, likes, comments or analytics. YouTube's API terms do not allow its data to be passed on through an API, so:

  • a breakout or an outlier on YouTube is not an event, and comment themes resting on YouTube comments are not sent;
  • a failed post to YouTube says "Shown in the app: what YouTube answers is not passed on through an API (YouTube’s API terms)." where its error would be;
  • jobs that read or write a platform for the workspace (analytics, Studio, the monetization tracker) send no job events at all.

Troubleshooting#

What you seeWhy, and what to do
"That address is on a private or reserved network."Webhooks go to public HTTPS endpoints only. Expose your receiver through a public address.
"We could not find … in DNS."The host name does not resolve: check it.
"That is a Slack address — choose Slack as the format …"Pick the matching format for chat addresses.
The endpoint shows Failing (N)The last deliveries failed for good. Open Deliveries: the answer column says what your endpoint returned, or "timed out after 10 seconds".
The endpoint shows PausedSomeone paused it, or 20 deliveries in a row failed for good. Fix the receiver, press Turn on, then Resend what you missed.
Your check refuses every deliveryYou are signing a re-serialised body, or using the secret without its whsec_. Test with the vector above.
Nothing arrives at allIs the event ticked for this endpoint? Is the plan still Pro or above? A REST hook stops when its key is revoked or expires.

Something missing or unclear? Tell us and we will improve this page.