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, ornullfor every type, those added later included.profileIds: only those profiles' events, ornullfor 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 itsurl, 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 (onBehalfOfisnull) 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
httpsaddresses 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 itsurlchanges.
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.publishedorpost.failed. - Each network of a post:
post.target.published,post.target.failed,post.target.updatedandpost.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) andmessage.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) andcomment_automation.paused(a person paused it, or Rizamo did, with why inpausedReason). - Accounts:
account.connected,account.disconnected(withreason),account.updated(moved to another profile) andaccount.removed(withreason).
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.receivedandmessage.receivedcome 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:readfor the inbox's types andautomations:readforcomment_automation.*, besidesevents:readorwebhooks:write. Without it,GET /v1/eventsleaves them out (and answers 403 when asked for one bytype), an endpoint cannot list them, an endpoint it makes with every type ("events": null), or points at a newurl, 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.sentcomes 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.receivedandmessage.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.reasonisneeds_login,facebook_action_needed,permission_missingoraccount_gone. Its posts wait in Rizamo, and a new login brings it back.account.removed: the account left the team.reasonisremoved_by_team(a person, key or agent removed it, named inactor, withposts, the counts the removal answered) ordata_deletion(its network asked Rizamo to delete its data). Events from before 5 November 2026 have noreason: there, anactormeans the team removed it, anddataDeletionthat the network asked.- A removal by the team also sends each post's change:
post.deletedfor a post that went to the account alone,post.updatedfor one that lost the account's part that never went out. Both carryaccountRemoved. Nothing is taken off the network, so nopost.target.deletedcomes. - A removed account's other events stay, as they were: they keep its
handle, and its posts' events theirurl, 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 Gonedisables 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 withPATCH /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 newprofileIds(ornull, every profile) in the samePATCH. - A new
urlstarts 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}/replaysends one delivery again now, delivered or failed: the body it sent before, signed anew, with the samewebhook-id, so a receiver that has it already can skip it. One more attempt, not retried.POST /v1/webhooks/{id}/replaywithsince(anduntil) 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;moresays the range holds more, andnextCursor, passed back ascursor, 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
urlas 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 afterwhsec_. 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.