oqimDocs
API referenceConsole

Start here

  • Introduction
  • Getting started

Guides

  • Telegram accounts
  • Recipients
  • Campaigns
  • Scheduling
  • Webhooks
  • AI writing assistant

AI Seller

  • AI Seller
  • Channels
  • Live inbox
  • Past conversations and privacy
  • Business insights

API

  • Authentication
  • API reference
  • SDK
  • Errors
  • Rate limits

Trust and operations

  • Security
  • Compliance
  • Administration
Docs / Webhooks

Start here

  • Introduction
  • Getting started

Guides

  • Telegram accounts
  • Recipients
  • Campaigns
  • Scheduling
  • Webhooks
  • AI writing assistant

AI Seller

  • AI Seller
  • Channels
  • Live inbox
  • Past conversations and privacy
  • Business insights

API

  • Authentication
  • API reference
  • SDK
  • Errors
  • Rate limits

Trust and operations

  • Security
  • Compliance
  • Administration

Webhooks

Webhooks push your organization's events to an HTTPS endpoint you run: campaign progress, deliveries, account health and opt-outs. Every request is signed, so you can check it came from Oqim.

Add an endpoint#

In the console, go to API & webhooks → Add webhook, enter the URL and choose the events to receive, or all of them. Over the API, POST /webhooks with events set to a list of types or ["*"]. Managing webhooks takes webhooks:manage (Owners, Admins and API keys), and the number of endpoints depends on your plan.

In production the URL must use HTTPS and resolve to a public address: Oqim refuses loopback, private and other reserved addresses when it connects, and never follows redirects. URLs can't carry credentials; verify the signature instead.

The response contains the endpoint's signing_secret, a string starting whsec_, shown once. Store it with your other secrets. Replace it any time with POST /webhooks/{id}/rotate-secret; deliveries are signed with the new secret from then on. POST /webhooks/{id}/test queues a webhook.test event so you can check your endpoint end to end.

Events#

EventSent whendata
campaign.scheduledA campaign was launched and waits for its start time or window. A run of a repeating campaign that Oqim launched carries automatic: true.campaign_id, total, start_at, series_id, occurrence, automatic
campaign.startedA campaign began sending.campaign_id, total
campaign.pausedA person, Oqim or a platform administrator paused a campaign. Automatic pauses carry automatic: true.campaign_id, reason, automatic, account_id or failure_rate
campaign.resumedA paused campaign was resumed.campaign_id, status
campaign.completedEvery recipient reached a final state, or the end time passed (then reason is end_time_reached).campaign_id, total, completed, failed, skipped, reason, cancelled
campaign.cancelledA campaign was cancelled.campaign_id
campaign.repeat_stoppedA repeating campaign stopped adding runs. reason is stopped (someone stopped it), count_reached, end_date_reached, run_cancelled, run_failed, run_deleted or no_next_date.campaign_id, series_id, reason, runs, deleted_run_ids, kept_run_ids
message.deliveredTelegram accepted a message. One event per recipient.campaign_id, recipient_id, account_id, job_id, result, telegram_message_id
message.failedA message ended without delivery: FAILED or RECIPIENT_UNAVAILABLE.campaign_id, recipient_id, account_id, job_id, result, error, code
account.connectedAn account finished signing in, or signed in again.account_id, telegram_user_id, username, reconnected
account.restrictedTelegram restricted an account; Oqim stopped it and paused its campaigns.account_id, status, reason, code
account.auth_requiredAn account's session ended; it needs to sign in again.account_id, status, reason, code
account.disconnectedAn account couldn't reach Telegram repeatedly, or was removed.account_id, status, reason, code
channels.account.updatedA connected account on Instagram, Facebook or YouTube connected, changed status or was disconnected.account_id, platform, status
channels.history.importedA history import finished, once per import, on any channel. Instagram and Facebook also announce an import that ended FAILED or CANCELLED; Telegram's payload has no status. This is the event that starts the analysis of the past conversations it imported.account_id, channel, chats_imported, messages_imported, and status on Instagram and Facebook
channels.private_reply.sentA comment-to-DM automation sent its one private reply to a commenter.reply_id, account_id, platform, automation_id, comment_id, post_external_id, trigger, reply_mode, message_id
channels.private_reply.failedMeta refused a private reply, or it couldn't be sent. Same fields, with error_code instead of message_id.reply_id, account_id, platform, automation_id, comment_id, post_external_id, trigger, reply_mode, error_code
channels.marketing_subscription.updatedA person opted in to a Facebook Page's marketing messages on a topic, stopped them or resumed them, or their token expired.subscription_id, account_id, topic, status
recipient.opted_outA recipient opted out: by link, recorded by your team, through the suppression list, from a conversation or an import. source is OPT_OUT_LINK, MANUAL, SUPPRESSION, CONVERSATION or IMPORT, and the conversation or import that caused it is named.recipient_id, source, and conversation_id or import_id
ai.conversation.messageA message was stored in an AI Seller conversation: the customer's, one typed on the owner's phone, or one Oqim queued. Carries no message text.conversation_id, message_id, account_id, channel, direction, author, status, plus new_conversation, execution_id and promoted_from_history where they apply
ai.conversation.message_updatedA stored message changed. change is sent, failed, cancelled, edited, deleted, reaction or read.conversation_id, message_id, change, status, plus channel, error_code, reaction, read_at, merged_into, telegram_message_id, external_message_id and send_basis where they apply
ai.conversation.stateThe AI's state in a conversation changed: taken over, resumed, handed off or stopped.conversation_id, account_id, channel, ai_state, previous, reason, handoff_id on a handoff
ai.conversation.opted_outThe customer asked to stop; the conversation is closed for good.conversation_id, account_id, channel, recipient_id, detection, keyword, message_id
ai.conversation.attachment_readyA message's file finished downloading into Oqim's storage; the message holds a pending attachment, so fetch it again or re-request the file.conversation_id, message_id, attachment_id, status
ai.handoff.createdA conversation was handed to a person (asked for a person, a complaint, a rule, or taken over).handoff_id, conversation_id, account_id, channel, trigger, assigned_user
ai.handoff.updatedA handoff was assigned or resolved.handoff_id, conversation_id, resolved, assigned_user
ai.turn.completedThe AI Seller finished a turn. final_action is SEND, DRAFT, RECOMMEND, NONE, HANDOFF, BLOCK or STOP.conversation_id, execution_id, final_action, stage, intent, draft_id, message_id, handoff_id
ai.reply.sentAn AI reply was delivered: in Telegram, or on Instagram or Facebook once Meta accepted it.conversation_id, message_id, execution_id, account_id, channel
ai.reply.draftedThe AI prepared a reply for a person instead of sending it (kind DRAFT or RECOMMENDATION).conversation_id, draft_id, kind, execution_id
ai.reply.blockedThe validators or a policy blocked a reply. codes say which checks failed.conversation_id, execution_id, codes, handoff_id
ai.draft.updatedA draft or recommendation was approved, discarded or superseded.conversation_id, draft_id, status, message_id
ai.stage.changedA conversation moved to another sales-funnel stage.conversation_id, from, to, source, execution_id, lead_id
ai.lead.updatedA lead was opened or changed.lead_id, conversation_id, stage, quality, status, created
ai.calendar.connectedA Google calendar was connected for the AI Seller.connection_id, calendar_id
ai.calendar.disconnectedThe calendar was disconnected, or Google refused its access (reason NEEDS_REAUTH: connect it again).connection_id, reason
ai.calendar.action_createdThe Calendar agent proposed, ran or refused a calendar action (a booking, a move, a cancellation, a lookup).action_id, conversation_id, type, status, event_id, starts_at
ai.calendar.action_updatedA calendar action was confirmed, rejected, completed, failed or expired.action_id, conversation_id, type, status, event_id
ai.social.syncedA social sync of an Instagram, Facebook or YouTube account finished.account_id, platform, posts, comments, analyzed
ai.social.narrative_readyA social board's narrative was written for a platform, an account, a business and a period, or was withheld. status is READY, PENDING, STALE or DISABLED, and a withheld narrative carries error_code.board, platform, account_id, business_id, locale, period_from, period_to, status, error_code
ai.analysis.updatedA conversation's case analysis changed: a LIVE or FINAL analysis, a person's correction, or a new explanation. dimensions lists the dimensions whose classification changed. Carries no message text.conversation_id, business_id, channel, phase, dimensions
ai.insights.narrative_readyAn insight board's narrative was written (or is current) for a business, a channel (null: all) and a period.board, business_id, channel, locale, period_from, period_to
ai.cases.generatedA business's case catalog was written or extended.business_id, added, total
ai.insights.backfillProgress of the analysis of past conversations, at most every 5 seconds while it runs, and when it ends (status DONE, FAILED or CANCELLED).business_id, status, total, done
webhook.testYou asked for a test delivery.webhook_id, message, sent_by

message.* events fire once per recipient, so a 10,000-person campaign sends 10,000 of them. Subscribe only if you need per-message detail; campaign.completed carries the totals.

The channel field

Wherever an event belongs to a chat, data.channel says which channel it is on: TELEGRAM, INSTAGRAM or FACEBOOK. It is always on ai.conversation.message, ai.conversation.state, ai.conversation.opted_out, channels.history.imported, ai.handoff.created, ai.reply.sent and ai.analysis.updated, and on ai.conversation.message_updated whenever Instagram, Facebook or one of Oqim's own sends raised it. On ai.insights.narrative_ready it is a scope rather than a conversation: the board's channel, null for all of them.

A few events carry no channel: Telegram's own edited and deleted messages, ai.conversation.attachment_ready, ai.handoff.updated, ai.turn.completed, ai.reply.blocked, ai.draft.updated, ai.stage.changed and ai.lead.updated. Each names the conversation it belongs to, so the channel reads off the conversation you already hold. Store the field when you receive it, and treat its absence as "look it up", not as Telegram. The channel-specific events — channels.account.updated, channels.private_reply.* and channels.marketing_subscription.updated — carry platform instead, which also covers YouTube.

Payload#

Every delivery is a JSON POST with the same envelope:

campaign.completed
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8j0",
  "event": "campaign.completed",
  "timestamp": "2026-09-30T13:41:07Z",
  "data": {
    "campaign_id": "cmp_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "total": 1840,
    "completed": 1827,
    "failed": 13,
    "skipped": 0
  }
}
  • id identifies the event and timestamp is when it happened. Deliveries are at least once, so the same event can arrive more than once: record the IDs you've processed and skip repeats.
  • data holds the fields listed above. In campaign.completed, completed counts delivered messages and failed includes unreachable recipients. Fetch the object from the API when you need more than the event carries.
  • Events usually arrive in the order they happened, but that isn't guaranteed. Use timestamp, or the object's current state, when order matters.

Payload examples#

Every delivery uses the envelope above, so these examples show only data. Identifiers starting with a prefix (cnv_, cha_, msg_…) are Oqim's; anything without one is the platform's own, kept as the platform wrote it. Timestamps are UTC.

Channels

channels.account.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h0",
  "event": "channels.account.updated",
  "timestamp": "2026-09-30T13:41:07Z",
  "data": {
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "platform": "INSTAGRAM",
    "status": "CONNECTED"
  }
}
  • platform is INSTAGRAM, FACEBOOK or YOUTUBE. Telegram accounts use their own events (account.connected, account.restricted, account.auth_required, account.disconnected).
  • status is CONNECTED, PUBLIC (YouTube, no sign-in), AUTH_REQUIRED, ERROR or DISCONNECTED.
channels.history.imported (Instagram or Facebook)
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h1",
  "event": "channels.history.imported",
  "timestamp": "2026-09-30T14:02:41Z",
  "data": {
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "INSTAGRAM",
    "status": "DONE",
    "chats_imported": 128,
    "messages_imported": 41207
  }
}
  • status is DONE, FAILED or CANCELLED, and Instagram and Facebook send it whether the import succeeded or not. The Telegram importer has no such field: it announces the import only when it finishes, with account_id, channel (TELEGRAM), chats_imported and messages_imported.
  • This is the event that starts the analysis of the past conversations it imported; watch ai.insights.backfill for its progress.
channels.private_reply.sent
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h2",
  "event": "channels.private_reply.sent",
  "timestamp": "2026-09-30T14:11:09Z",
  "data": {
    "reply_id": "cpr_01j9r8w4y6a8c0e2g4j6m8p0r2",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "platform": "INSTAGRAM",
    "automation_id": "cau_01j9r8w4y6a8c0e2g4j6m8p0t4",
    "comment_id": "17912345678901234",
    "post_external_id": "17901234567890123",
    "trigger": "KEYWORDS",
    "reply_mode": "TEMPLATE",
    "message_id": "aWdfZGFybjoxNzkxMjM0NTY3ODkwMTIzNA"
  }
}
channels.private_reply.failed
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h3",
  "event": "channels.private_reply.failed",
  "timestamp": "2026-09-30T14:11:09Z",
  "data": {
    "reply_id": "cpr_01j9r8w4y6a8c0e2g4j6m8p0r2",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "platform": "INSTAGRAM",
    "automation_id": "cau_01j9r8w4y6a8c0e2g4j6m8p0t4",
    "comment_id": "17912345678901234",
    "post_external_id": "17901234567890123",
    "trigger": "JEV_PURCHASE_INTEREST",
    "reply_mode": "SELLER",
    "error_code": "GRAPH_10"
  }
}
  • comment_id, post_external_id and message_id are Meta's: the comment, the post it is on and the message Meta accepted.
  • trigger is KEYWORDS or JEV_PURCHASE_INTEREST; reply_mode is TEMPLATE or SELLER, and it is empty on a failed reply, because Oqim records it when the reply goes out.
  • automation_id is the automation whose trigger fired, and post_external_id is left out for a comment that isn't on a post.
  • error_code is GRAPH_ plus Meta's numeric code, or ACCOUNT_AUTH_REQUIRED when the account's token is gone. A reply that Meta refused because of a rate limit is not a failure: it stays pending and is retried.
channels.marketing_subscription.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h4",
  "event": "channels.marketing_subscription.updated",
  "timestamp": "2026-09-30T15:20:33Z",
  "data": {
    "subscription_id": "mms_01j9r9a6c8e0g2j4m6p8r0t2v4",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "topic": "POST_PURCHASE_UPDATE",
    "status": "ACTIVE"
  }
}

Facebook Pages only: Instagram has no marketing messages. topic is the one Meta sent (for example POST_PURCHASE_UPDATE or ACCOUNT_UPDATE); it is empty when this is a status-change webhook rather than an opt-in. status is ACTIVE (the opt-in is current), STOPPED (Oqim must stop sending on that topic) or EXPIRED, when the person's token ran out and Oqim can no longer message them on that topic.

The opt-in row also carries a conversation_id when the sender's peer maps to a conversation, but the webhook itself names only account_id, topic and status.

Conversations

ai.conversation.message
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h5",
  "event": "ai.conversation.message",
  "timestamp": "2026-09-30T16:05:12Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "INSTAGRAM",
    "direction": "IN",
    "author": "CUSTOMER",
    "status": "RECEIVED",
    "new_conversation": true
  }
}
  • direction is IN or OUT; author is CUSTOMER, AI, OWNER_PHONE (typed in the Instagram, Facebook or Telegram app itself) or OWNER.
  • new_conversation is true when this message opened the chat, and promoted_from_history is true when the account went on to message a chat Oqim had imported as history: the moment the imported history stops being inert.
  • A message Oqim queued carries execution_id and status QUEUED. On Telegram account_id is the Telegram account (acc_); on Instagram and Facebook it is the connected account (cha_).
ai.conversation.message_updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h6",
  "event": "ai.conversation.message_updated",
  "timestamp": "2026-09-30T16:05:14Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "channel": "INSTAGRAM",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a8",
    "change": "sent",
    "status": "SENT",
    "external_message_id": "m_1aBcDeFgHiJkLmNoPqRsTuVwXyZ",
    "send_basis": "REPLY_WINDOW"
  }
}
  • change is sent, failed, cancelled, edited, deleted, reaction or read. Failure and cancellation carry error_code; a reaction carries reaction and a read receipt carries read_at.
  • send_basis is why an Oqim send was allowed: REPLY_WINDOW, CONSENT (Telegram, outside the window, on recorded consent) or HUMAN_AGENT (a person answered on Instagram or Facebook between 24 hours and 7 days with Meta's tag).
  • merged_into marks a duplicate Oqim deleted in favour of the message the platform actually stored, and telegram_message_id or external_message_id is the platform's id for the message.
ai.conversation.attachment_ready
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h7",
  "event": "ai.conversation.attachment_ready",
  "timestamp": "2026-09-30T16:05:19Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7",
    "attachment_id": "cma_01j9r5g8j0l2n4q6s8u0w2y4a9",
    "status": "STORED"
  }
}

status is STORED: the file is in Oqim's storage and can be read or served. A file that cannot be fetched fires nothing and ends TOO_LARGE, UNAVAILABLE or FAILED; that is on the attachment inside the message, where the reason is readable.

ai.conversation.state
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h8",
  "event": "ai.conversation.state",
  "timestamp": "2026-09-30T16:06:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "INSTAGRAM",
    "ai_state": "PAUSED_HANDOFF",
    "previous": "ACTIVE",
    "reason": "The customer asked for a person.",
    "handoff_id": "hof_01j9r3y5a7c9e1g3j5m7p9r1t3"
  }
}
  • ai_state is ACTIVE, PAUSED_HANDOFF (a person has the conversation), STOPPED (the customer opted out; permanent) or OFF.
  • reason is a short sentence, not a code — a handoff's reason, "resumed" when the AI is handed back, or "opted out earlier" when an import found the stop request.
  • handoff_id is present when the AI paused for a handoff. A resumed conversation that settled open handoffs announces them separately as ai.handoff.updated.
ai.conversation.opted_out
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8h9",
  "event": "ai.conversation.opted_out",
  "timestamp": "2026-09-30T16:08:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "TELEGRAM",
    "recipient_id": "rcp_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "detection": "KEYWORD",
    "keyword": "stop",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7"
  }
}
  • detection is KEYWORD or CLASSIFIER; keyword is the matched stop word when detection is KEYWORD.
ai.handoff.created
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8ha",
  "event": "ai.handoff.created",
  "timestamp": "2026-09-30T16:10:00Z",
  "data": {
    "handoff_id": "hof_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "INSTAGRAM",
    "trigger": "HUMAN_REQUESTED",
    "assigned_user": null
  }
}
  • trigger is HUMAN_REQUESTED, LOW_CONFIDENCE, COMPLAINT, SENSITIVE_TOPIC, PRICING_EXCEPTION, HIGH_VALUE_LEAD, POLICY_UNCERTAINTY, CALENDAR_AMBIGUITY, REPEATED_FAILURES, CUSTOM (your own rule) or MANUAL (a person took the conversation over).
  • assigned_user is null until somebody claims the handoff. The reason behind the trigger is on the handoff itself, not in this event.
ai.handoff.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hb",
  "event": "ai.handoff.updated",
  "timestamp": "2026-09-30T16:12:00Z",
  "data": {
    "handoff_id": "hof_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "resolved": false,
    "assigned_user": "usr_01j9r2w4y6a8c0e2g4j6m8p0r2"
  }
}

A handoff was assigned to somebody or resolved. assigned_user is the person who has it, or null. Resolving a handoff does not hand the conversation back to the AI: POST /ai/conversations/{id}/resume does that, and it sends ai.conversation.state with ai_state ACTIVE. Resuming a conversation that had open handoffs announces each of them here with resolved: true.

Seller

ai.turn.completed
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hc",
  "event": "ai.turn.completed",
  "timestamp": "2026-09-30T16:25:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "execution_id": "exe_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "final_action": "SEND",
    "stage": "ENGAGED",
    "intent": "pricing_question",
    "draft_id": null,
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7",
    "handoff_id": null
  }
}
  • final_action is SEND, DRAFT, RECOMMEND, NONE,HANDOFF, BLOCK or STOP.
  • draft_id is present when final_action is DRAFT or RECOMMEND.
  • handoff_id is present when final_action is HANDOFF.
  • stage is the conversation's sales-funnel stage key — yours, so an Oqim default funnel gives NEW, ENGAGED, DISCOVERY, QUALIFICATION, INTERESTED, OFFER_PRESENTED, OBJECTION, READY_TO_CONVERT and CONVERTED. intent is JEV's answer, so it is empty when JEV did not trust one.
ai.reply.sent
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hd",
  "event": "ai.reply.sent",
  "timestamp": "2026-09-30T16:25:05Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7",
    "execution_id": "exe_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y4",
    "channel": "INSTAGRAM"
  }
}

Fires when the platform accepted the AI reply: Telegram, or Instagram/Facebook after Meta accepted it.

ai.reply.drafted
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8he",
  "event": "ai.reply.drafted",
  "timestamp": "2026-09-30T16:26:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "draft_id": "dft_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "kind": "DRAFT",
    "execution_id": "exe_01j9r3y5a7c9e1g3j5m7p9r1t3"
  }
}
  • kind is DRAFT (a message ready to send) or RECOMMENDATION (advice for the person answering).
ai.reply.blocked
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hf",
  "event": "ai.reply.blocked",
  "timestamp": "2026-09-30T16:28:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "execution_id": "exe_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "codes": ["UNGROUNDED_FACT", "REPETITION"],
    "handoff_id": null
  }
}
  • codes says which checks failed. The validators: UNGROUNDED_FACT, TOO_LONG, PROHIBITED_PHRASE, PROHIBITED_ACTION, CLAIMS_HUMAN, AI_QUESTION_UNANSWERED, LANGUAGE_MISMATCH, REPETITION, TOO_MANY_QUESTIONS, ASKS_FOR_SECRETS, EMPTY_REPLY, INVALID_ACTION, DISCLOSURE_MISSING. A policy that stopped the turn instead: OPTED_OUT, SUPPRESSED, CONVERSATION_STOPPED, AI_PAUSED, AI_OFF, NO_CONSENT, FOLLOW_UP_LIMIT, PERSONAL or NO_REPLY.
  • A blocked reply is never kept as a draft, and the model gets one regeneration with the failures as instructions first. handoff_id is present when repeated failures handed the conversation over.
ai.draft.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hg",
  "event": "ai.draft.updated",
  "timestamp": "2026-09-30T16:30:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "draft_id": "dft_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "status": "APPROVED",
    "message_id": "msg_01j9r5g8j0l2n4q6s8u0w2y4a7"
  }
}
  • status is APPROVED, DISCARDED or SUPERSEDED. When approved,message_id is the sent message.
ai.stage.changed
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hh",
  "event": "ai.stage.changed",
  "timestamp": "2026-09-30T16:32:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "from": "NEW",
    "to": "ENGAGED",
    "source": "AI",
    "execution_id": "exe_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "lead_id": "led_01j9r2w4y6a8c0e2g4j6m8p0r2"
  }
}
  • source is AI, SYSTEM or HUMAN.
ai.lead.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hi",
  "event": "ai.lead.updated",
  "timestamp": "2026-09-30T16:34:00Z",
  "data": {
    "lead_id": "led_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "stage": "ENGAGED",
    "quality": "warm",
    "status": "OPEN",
    "created": true
  }
}
  • quality is JEV's lead quality, written as it answered it: cold (no clear need or intent to buy), warm (a real need, nothing to act on yet) or hot (a sign of acting soon).
  • status is OPEN, WON (the conversation reached the funnel's last stage) or LOST (the customer opted out). created is true only on the event that opened the lead, and lead_id is on ai.stage.changed only once a lead exists.

Analysis and insights

ai.analysis.updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hn",
  "event": "ai.analysis.updated",
  "timestamp": "2026-09-30T16:40:00Z",
  "data": {
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "business_id": "biz_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "channel": "INSTAGRAM",
    "phase": "FINAL",
    "dimensions": ["PURCHASE_OUTCOME", "LOST_REASON"]
  }
}
  • phase is LIVE while the conversation is going on or FINAL once it has ended or gone quiet.
  • dimensions names only the dimensions whose classification changed: PURCHASE_OUTCOME, LOST_REASON, DISSATISFACTION, DEMAND or SERVICE_QUALITY. A person's correction, and an explanation that only rewrites prose and moves no classification, arrive with an empty list.
ai.cases.generated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8ho",
  "event": "ai.cases.generated",
  "timestamp": "2026-09-30T16:50:00Z",
  "data": {
    "business_id": "biz_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "added": 6,
    "total": 34
  }
}

added is how many cases the run wrote into the catalog and total how many the business has now. A run that finds nothing to add sends added: 0, and the catalog was extended rather than replaced.

ai.insights.backfill
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hp",
  "event": "ai.insights.backfill",
  "timestamp": "2026-09-30T16:55:00Z",
  "data": {
    "business_id": "biz_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "status": "RUNNING",
    "total": 1200,
    "done": 340
  }
}

The analysis of past conversations announces itself at most every 5 seconds while it runs (throttled per run), and again when it ends, with status DONE, FAILED or CANCELLED. business_id is null when the run covers every business in the organization. The console shows the same numbers as "analysing past conversations: 340 of 1,200".

ai.insights.narrative_ready
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hq",
  "event": "ai.insights.narrative_ready",
  "timestamp": "2026-09-30T17:00:00Z",
  "data": {
    "board": "LOST_REASONS",
    "business_id": "biz_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "channel": null,
    "locale": "uz",
    "period_from": "2026-08-29",
    "period_to": "2026-09-27"
  }
}
  • board is one of BUSINESS_HEALTH, SALES_OUTCOMES, LOST_REASONS, HAPPINESS, DEMAND or SERVICE_QUALITY. channel is null when the board covers every channel and business_id is null when it covers every business.
  • locale is uz, ru or en, and the period is the board's window, 30 days by default.

Social

ai.social.synced
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hr",
  "event": "ai.social.synced",
  "timestamp": "2026-09-30T17:15:00Z",
  "data": {
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y5",
    "platform": "INSTAGRAM",
    "posts": 42,
    "comments": 613,
    "analyzed": 655
  }
}

posts and comments count what the sync read, and analyzed how many of them went through case analysis. A sync that lands no new content still reports zeroes.

ai.social.narrative_ready
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hs",
  "event": "ai.social.narrative_ready",
  "timestamp": "2026-09-30T17:30:00Z",
  "data": {
    "board": "CONTENT",
    "platform": "INSTAGRAM",
    "account_id": "cha_01j9r4c6e8g0j2m4p6r8t0w2y5",
    "business_id": "biz_01j9r2w4y6a8c0e2g4j6m8p0r2",
    "locale": "ru",
    "period_from": "2026-08-29",
    "period_to": "2026-09-27",
    "status": "READY"
  }
}
  • board is SOCIAL_OVERVIEW, CONTENT, AUDIENCE or GROWTH, and platform is INSTAGRAM, FACEBOOK or YOUTUBE. account_id is null for a board over every account of a platform.
  • status is READY, PENDING (STALE still returns the earlier text) or DISABLED for a withheld narrative, which adds error_code: ungrounded, wrong_language, ai_unavailable, invalid_output or budget_exceeded.

Calendar

ai.calendar.connected
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8ht",
  "event": "ai.calendar.connected",
  "timestamp": "2026-09-30T17:35:00Z",
  "data": {
    "connection_id": "coc_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "calendar_id": "primary"
  }
}
ai.calendar.disconnected
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hu",
  "event": "ai.calendar.disconnected",
  "timestamp": "2026-09-30T17:36:00Z",
  "data": {
    "connection_id": "coc_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "reason": "NEEDS_REAUTH"
  }
}

reason is DISCONNECTED (somebody removed it) or NEEDS_REAUTH (Google refused the access; the console asks the owner to connect the calendar again). calendar_id is Google's own id for the calendar it writes to.

ai.calendar.action_created
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hv",
  "event": "ai.calendar.action_created",
  "timestamp": "2026-09-30T17:45:00Z",
  "data": {
    "action_id": "cac_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "type": "CREATE",
    "status": "AWAITING_CUSTOMER",
    "event_id": null,
    "starts_at": "2026-10-02T09:30:00Z"
  }
}
  • type is CREATE, UPDATE, CANCEL, FIND or CHECK_AVAILABILITY; the first three change the calendar.
  • status is AWAITING_CUSTOMER, PENDING (a person confirms it in the console), EXECUTING, COMPLETED, REJECTED, FAILED or EXPIRED. event_id is Google's own event id and starts_at the proposed time; both are null while the action has not reached the calendar. ai.calendar.action_updated carries the same fields, so an action is followed by the same event type as it moves on.
ai.calendar.action_updated
{
  "id": "evt_01j9r7m2p4s6v8x0z2b4d6f8hw",
  "event": "ai.calendar.action_updated",
  "timestamp": "2026-09-30T17:50:00Z",
  "data": {
    "action_id": "cac_01j9r3y5a7c9e1g3j5m7p9r1t3",
    "conversation_id": "cnv_01j9r5g8j0l2n4q6s8u0w2y4a6",
    "type": "CREATE",
    "status": "COMPLETED",
    "event_id": "g_1aBcDeFgHiJkLmNoPqRsTuVwXyZ",
    "starts_at": "2026-10-02T09:30:00Z"
  }
}

The customer confirmed the time, a person approved it in the console, and the action ran on the calendar. A rejected, failed or expired action arrives the same way, with its own status.

Headers#

HeaderValue
Oqim-EventThe event type, e.g. campaign.completed.
Oqim-DeliveryThe delivery ID (dlv_…). It stays the same across retries of that delivery.
Oqim-Signaturet=<unix seconds>,v1=<hex HMAC>, for example t=1759239667,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Content-Typeapplication/json
User-AgentOqim-Webhooks/1

Verify signatures#

v1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw body>, keyed with the endpoint's signing secret. To verify a delivery:

  1. Split the header on commas and read t and v1.
  2. Reject the delivery if t is more than 5 minutes away from your clock. This stops an intercepted request from being replayed later.
  3. Compute the HMAC of t, a period and the raw request body, exactly as received.
  4. Compare it with v1 in constant time.
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.OQIM_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

export function verifyOqimSignature(rawBody, header, now = Date.now()) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(now / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

const app = express();

// express.raw keeps the exact bytes Oqim signed.
app.post("/oqim/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyOqimSignature(req.body, req.get("Oqim-Signature") ?? "")) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Deliveries are at-least-once: skip event.id values you've already handled.
  res.sendStatus(204);
});

app.listen(3000);
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// Verify checks an Oqim-Signature header against the raw request body.
func Verify(secret string, body []byte, header string, now time.Time) error {
	var ts int64
	var sig []byte
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			ts, _ = strconv.ParseInt(v, 10, 64)
		case "v1":
			sig, _ = hex.DecodeString(v)
		}
	}
	if ts == 0 || len(sig) == 0 {
		return errors.New("malformed signature header")
	}
	if d := now.Sub(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
		return errors.New("timestamp outside tolerance")
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
	mac.Write(body)
	if !hmac.Equal(sig, mac.Sum(nil)) {
		return errors.New("signature mismatch")
	}
	return nil
}

func Handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}
		if err := Verify(secret, body, r.Header.Get("Oqim-Signature"), time.Now()); err != nil {
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}
		// Decode the body, skip event IDs you've already processed, then acknowledge.
		w.WriteHeader(http.StatusNoContent)
	}
}
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["OQIM_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60


def verify(raw_body: bytes, header: str, now: float | None = None) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        given = bytes.fromhex(parts["v1"])
    except (KeyError, ValueError):
        return False
    if abs((now if now is not None else time.time()) - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).digest()
    return hmac.compare_digest(given, expected)


app = Flask(__name__)


@app.post("/oqim/webhooks")
def oqim_webhook():
    if not verify(request.get_data(), request.headers.get("Oqim-Signature", "")):
        abort(400)
    event = request.get_json()
    # Deliveries are at-least-once: skip event["id"] values you've already handled.
    return "", 204

Sign the bytes, not the JSON

Verify against the body exactly as it arrived. Parsing the JSON and serializing it again changes whitespace and key order, and the signature won't match.

Responses, retries and disabling#

  • Answer with any 2xx status as soon as you've stored the event, and do slow work afterwards. An attempt that gets no response within 10 seconds counts as failed. Redirects count as failures too.
  • Any other status, a timeout or a connection error is a failure. Oqim retries with exponential backoff, up to 10 times, then marks the delivery failed.
  • After 25 failed attempts in a row, across deliveries, the endpoint is disabled: its status becomes DISABLED, failure_streak shows the count and the organization gets a notification. Fix the endpoint, then re-enable it in the console or with PATCH /webhooks/{id} and {"status": "ACTIVE"}. One successful delivery resets the streak.
  • The latest 100 deliveries are listed under the webhook in the console and at GET /webhooks/{id}/deliveries, with attempts, the response status, the first 2 KB of the response body and how long it took.

Prefer pulling? GET /events lists the same events, and GET /events/stream streams them as server-sent events. See the API reference.

PreviousSchedulingNext AI writing assistant