rizamodocs

Webhooks

A signed POST to your URL each time a post or an account changes, a comment or message arrives, or a reply goes out.

Endpoints

Add an endpoint under Developers > Webhooks, or with the API (POST /v1/webhooks, scope webhooks:write). In the web app, adding an endpoint, changing its URL or rotating its secret asks you to sign in again if you signed in more than 15 minutes ago. Agents on MCP read endpoints and deliveries (list_webhooks, list_webhook_deliveries) but never make or change them, so a comment an agent reads cannot send your events elsewhere.

curl https://api.rizamo.com/v1/webhooks \
  -H "Authorization: Bearer $RIZAMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/rizamo",
    "events": ["post.published", "post.failed"],
    "profileIds": ["prof_..."]
  }'
  • events: the event types it gets, or null for every type, those added later included.
  • profileIds: only those profiles' events, or null for every profile's and the events of no profile. A key or person limited to some profiles makes endpoints for those profiles only.
  • Each endpoint sends for a person (onBehalfOf): whoever made it or last changed its url, or the person who made the API key or connected the agent that did. It gets only events of the profiles that person reaches now, and is deleted when they leave the team. One whose person is gone (onBehalfOf is null) sends nothing until an owner changes it, which makes them its person.
  • Owners see and manage every endpoint, also with their API keys. Anyone else sees only the ones that send for them.
  • The answer has the endpoint's signing secret (whsec_...). It is shown only then, and when you rotate it.
  • Webhooks are on every plan, the free one included. A team has up to 20 endpoints.
  • Rizamo sends only to https addresses on the public internet, never to rizamo.com or workers.dev, and never follows a redirect.
  • Changing an endpoint's types or profiles also applies to deliveries still waiting for a retry: one it no longer takes ends unsent (endpoint_changed), even after its url changes.

What arrives

One POST per event, with the event as GET /v1/events gives it:

{
  "id": "ev_01jbx3k9q8f7t2r5m4n6p8s0vw",
  "type": "post.published",
  "at": "2026-11-02T09:00:04.512Z",
  "actor": null,
  "summary": "\"Autumn menu\" published on Instagram and Facebook",
  "resource": { "type": "post", "id": "post_01jbx3k9q8f7t2r5m4n6p8s0vx" },
  "data": { "status": "published", "published": [{ "accountId": "acc_...", "network": "instagram", "format": "feed", "url": "https://www.instagram.com/p/..." }], "failed": [] }
}

actor is who made the change: a person, an API key, an agent, or null when Rizamo did it. An agent's says whose it is, the person who connected it and whom it acts as, and the summary names them both:

"actor": { "type": "agent", "agent": "claudecode", "name": "Claude Code", "agentId": "agac_...", "onBehalfOf": { "userId": "user_...", "name": "Ana Costa" } },
"summary": "Ana Costa via Claude Code scheduled \"Autumn menu\" for Mon 2 Nov, 09:00"

onBehalfOf is left out of an agent with no person. Once that person is erased (they asked us to delete their data, or their team was closed), it reads "name": "A former member" with an empty userId.

Answer with any 2xx within 15 seconds. Anything else is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, about 18 hours in all, then the delivery is failed. A 410 Gone disables the endpoint. Every attempt shows under Deliveries and in GET /v1/webhooks/deliveries for 30 days.

Deliveries are not in order, and the same one can arrive twice. Use webhook-id (the event's id) to skip one you already have, and at to order one post's or account's events.

POST /v1/webhooks/{id}/test sends a webhook.test delivery now, signed the same way, up to 10 a minute per team.

Event types

An endpoint takes every type ("events": null, those added later included) or the ones it lists:

  • Posts: post.created, post.updated, post.scheduled, post.unscheduled, post.deleted, post.restored, and the result once every network is done, post.published or post.failed.
  • Each network of a post: post.target.published, post.target.failed, post.target.updated and post.target.deleted, with the account, the network and its reason when it failed.
  • Inbox: comment.received (a comment, or a reply under one), message.received (a private message to the account) and message.sent (a reply sent from Rizamo, by a person, an API key, an agent or a comment automation, once it is on the network).
  • Comment automations: comment_automation.sent (it sent its message to someone who commented), comment_automation.failed (the network refused it, or Rizamo cannot tell whether it went out, and it is not sent again) and comment_automation.paused (a person paused it, or Rizamo did, with why in pausedReason).
  • Accounts: account.connected, account.disconnected (with reason), account.updated (moved to another profile) and account.removed (with reason).

What each type's data holds is in the API reference, under GET /v1/events. A comment, with the ids to fetch the rest (GET /v1/inbox/threads/{threadId}):

{
  "id": "ev_01jc2m7d4e9f8g6h5j3k2n1p0q",
  "type": "comment.received",
  "at": "2026-11-03T10:15:02.118Z",
  "actor": null,
  "summary": "New comment from Joana Mendes on Instagram",
  "resource": { "type": "thread", "id": "thr_..." },
  "data": {
    "threadId": "thr_...",
    "messageId": "msg_...",
    "kind": "comment",
    "network": "instagram",
    "accountId": "acc_...",
    "networkPostId": "npost_...",
    "postId": "post_...",
    "author": { "name": "Joana Mendes", "handle": "@joana.mendes" },
    "text": "Is the pumpkin latte vegan?",
    "reply": false,
    "hidden": false
  }
}
  • comment.received and message.received come once per comment or message from someone else, the first time Rizamo stores it, however it arrives (the network's webhook or Rizamo's next read). Never for the account's own, and never for one written more than 72 hours before Rizamo found it: when an account connects, Rizamo reads its last 30 days of comments and messages into the inbox without an event for each.
  • They carry what people wrote, so an API key or agent needs inbox:read for the inbox's types and automations:read for comment_automation.*, besides events:read or webhooks:write. Without it, GET /v1/events leaves them out (and answers 403 when asked for one by type), an endpoint cannot list them, an endpoint it makes with every type ("events": null), or points at a new url, gets none of them, and the deliveries it reads show none of them. Endpoints made by people get them all.
  • A Threads or YouTube reply goes out in the background: its message.sent comes when it is on the network, not when it was asked for.
  • Removing an account deletes its inbox and automation events, with their deliveries, as it deletes its comments and messages. Connecting it again reads its recent comments and messages again, so those written in the last 72 hours come again as comment.received and message.received, with new ids. A comment removed on its network takes its events with it, and the automation events that quote it.
  • An event stays in the profile it happened in: an account moved to another profile brings its new events there, while its earlier ones stay with the profile it was in.

Disconnected or removed

  • account.disconnected: the account is still in the team, but its login stopped working. reason is needs_login, facebook_action_needed, permission_missing or account_gone. Its posts wait in Rizamo, and a new login brings it back.
  • account.removed: the account left the team. reason is removed_by_team (a person, key or agent removed it, named in actor, with posts, the counts the removal answered) or data_deletion (its network asked Rizamo to delete its data). Events from before 5 November 2026 have no reason: there, an actor means the team removed it, and dataDeletion that the network asked.
  • A removal by the team also sends each post's change: post.deleted for a post that went to the account alone, post.updated for one that lost the account's part that never went out. Both carry accountRemoved. Nothing is taken off the network, so no post.target.deleted comes.
  • A removed account's other events stay, as they were: they keep its handle, and its posts' events their url, for as long as the post exists. YouTube channels too.

When an endpoint keeps failing

  • After 10 failed attempts in a row the endpoint is failing: its deliveries wait, and one at a time tries it every 15 minutes. The first one delivered sends the rest at once. A test that gets through does the same. A try that fails goes back to waiting, without using up that delivery's retries.
  • Failures further apart than the longest wait between retries (10 hours) start a new count, so an endpoint that fails now and then is never disabled for it.
  • With the endpoint failing like that and nothing getting through for 5 days, Rizamo disables it. A 410 Gone disables it at once.
  • The team's owners, and the editor it sends for, get an email when it starts failing (at most once a day) and when it is disabled.
  • A disabled endpoint sends nothing, and its waiting deliveries end at once (endpoint_disabled). Enable it again with PATCH /v1/webhooks/{id} and "enabled": true: it takes new events, with its failures forgotten.
  • Deleting every profile an endpoint is limited to disables it too (profile_deleted), with no profile left: enable it again with its new profileIds (or null, every profile) in the same PATCH.
  • A new url starts the count again: the deliveries it had tried or held at the old address end (endpoint_changed), so a test at another address never sends them. Replay them to send them to the new one.

Replay

  • POST /v1/webhooks/deliveries/{id}/replay sends one delivery again now, delivered or failed: the body it sent before, signed anew, with the same webhook-id, so a receiver that has it already can skip it. One more attempt, not retried.
  • POST /v1/webhooks/{id}/replay with since (and until) sends again an endpoint's deliveries made in that time: the failed ones, or every one with "status": "all". Up to 1,000 a call, in the order they were made; more says the range holds more, and nextCursor, passed back as cursor, continues after the last one. One range at a time: a call while the endpoint's last replay is still being sent answers 409.
  • A replay goes to the endpoint's url as it is now. Both need the endpoint enabled, and skip what it no longer takes (its types or profiles changed since).
  • A team replays up to 5,000 deliveries an hour, one at a time and by range together; more answer 429.

Verify the signature

Rizamo signs as Standard Webhooks does, so its libraries verify our deliveries. Each request has three headers:

  • webhook-id: the event's id, the same on every retry.
  • webhook-timestamp: when this attempt was sent, in seconds.
  • webhook-signature: v1, and the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the base64 after whsec_. After a rotation it carries two, separated by a space, for 24 hours.

With the standardwebhooks package:

import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.RIZAMO_WEBHOOK_SECRET!);

export async function POST(request: Request) {
  const body = await request.text(); // the raw body, before any JSON parsing
  const event = wh.verify(body, Object.fromEntries(request.headers)); // throws when it does not match
  // ...
  return new Response(null, { status: 204 });
}

Or by hand, in Node:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret: string, headers: Headers, body: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const timestamp = Number(headers.get("webhook-timestamp"));
  if (!Number.isFinite(timestamp)) return false; // missing or not a number
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // older than 5 minutes: a replay
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
    const given = Buffer.from(part.split(",")[1] ?? "", "base64");
    return part.startsWith("v1,") && given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Verify the raw body: parsing and serializing it again changes the bytes.

On this page