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#
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:
ididentifies the event andtimestampis 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.dataholds the fields listed above. Incampaign.completed,completedcounts delivered messages andfailedincludes 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
platformisINSTAGRAM,FACEBOOKorYOUTUBE. Telegram accounts use their own events (account.connected,account.restricted,account.auth_required,account.disconnected).statusisCONNECTED,PUBLIC(YouTube, no sign-in),AUTH_REQUIRED,ERRORorDISCONNECTED.
statusisDONE,FAILEDorCANCELLED, 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, withaccount_id,channel(TELEGRAM),chats_importedandmessages_imported.- This is the event that starts the analysis of the past conversations it imported; watch
ai.insights.backfillfor its progress.
comment_id,post_external_idandmessage_idare Meta's: the comment, the post it is on and the message Meta accepted.triggerisKEYWORDSorJEV_PURCHASE_INTEREST;reply_modeisTEMPLATEorSELLER, and it is empty on a failed reply, because Oqim records it when the reply goes out.automation_idis the automation whose trigger fired, andpost_external_idis left out for a comment that isn't on a post.error_codeisGRAPH_plus Meta's numeric code, orACCOUNT_AUTH_REQUIREDwhen 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.
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
directionisINorOUT;authorisCUSTOMER,AI,OWNER_PHONE(typed in the Instagram, Facebook or Telegram app itself) orOWNER.new_conversationis true when this message opened the chat, andpromoted_from_historyis 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_idandstatusQUEUED. On Telegramaccount_idis the Telegram account (acc_); on Instagram and Facebook it is the connected account (cha_).
changeissent,failed,cancelled,edited,deleted,reactionorread. Failure and cancellation carryerror_code; a reaction carriesreactionand a read receipt carriesread_at.send_basisis why an Oqim send was allowed:REPLY_WINDOW,CONSENT(Telegram, outside the window, on recorded consent) orHUMAN_AGENT(a person answered on Instagram or Facebook between 24 hours and 7 days with Meta's tag).merged_intomarks a duplicate Oqim deleted in favour of the message the platform actually stored, andtelegram_message_idorexternal_message_idis the platform's id for the message.
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_stateisACTIVE,PAUSED_HANDOFF(a person has the conversation),STOPPED(the customer opted out; permanent) orOFF.reasonis 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_idis present when the AI paused for a handoff. A resumed conversation that settled open handoffs announces them separately asai.handoff.updated.
detectionisKEYWORDorCLASSIFIER;keywordis the matched stop word when detection isKEYWORD.
triggerisHUMAN_REQUESTED,LOW_CONFIDENCE,COMPLAINT,SENSITIVE_TOPIC,PRICING_EXCEPTION,HIGH_VALUE_LEAD,POLICY_UNCERTAINTY,CALENDAR_AMBIGUITY,REPEATED_FAILURES,CUSTOM(your own rule) orMANUAL(a person took the conversation over).assigned_userisnulluntil somebody claims the handoff. The reason behind the trigger is on the handoff itself, not in this event.
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
final_actionisSEND,DRAFT,RECOMMEND,NONE,HANDOFF,BLOCKorSTOP.draft_idis present whenfinal_actionisDRAFTorRECOMMEND.handoff_idis present whenfinal_actionisHANDOFF.stageis the conversation's sales-funnel stage key — yours, so an Oqim default funnel givesNEW,ENGAGED,DISCOVERY,QUALIFICATION,INTERESTED,OFFER_PRESENTED,OBJECTION,READY_TO_CONVERTandCONVERTED.intentis JEV's answer, so it is empty when JEV did not trust one.
Fires when the platform accepted the AI reply: Telegram, or Instagram/Facebook after Meta accepted it.
kindisDRAFT(a message ready to send) orRECOMMENDATION(advice for the person answering).
codessays 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,PERSONALorNO_REPLY.- A blocked reply is never kept as a draft, and the model gets one regeneration with the failures as instructions first.
handoff_idis present when repeated failures handed the conversation over.
statusisAPPROVED,DISCARDEDorSUPERSEDED. When approved,message_idis the sent message.
sourceisAI,SYSTEMorHUMAN.
qualityis 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) orhot(a sign of acting soon).statusisOPEN,WON(the conversation reached the funnel's last stage) orLOST(the customer opted out).createdis true only on the event that opened the lead, andlead_idis onai.stage.changedonly once a lead exists.
Analysis and insights
phaseisLIVEwhile the conversation is going on orFINALonce it has ended or gone quiet.dimensionsnames only the dimensions whose classification changed:PURCHASE_OUTCOME,LOST_REASON,DISSATISFACTION,DEMANDorSERVICE_QUALITY. A person's correction, and an explanation that only rewrites prose and moves no classification, arrive with an empty list.
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.
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".
boardis one ofBUSINESS_HEALTH,SALES_OUTCOMES,LOST_REASONS,HAPPINESS,DEMANDorSERVICE_QUALITY.channelisnullwhen the board covers every channel andbusiness_idisnullwhen it covers every business.localeisuz,ruoren, and the period is the board's window, 30 days by default.
Social
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.
boardisSOCIAL_OVERVIEW,CONTENT,AUDIENCEorGROWTH, andplatformisINSTAGRAM,FACEBOOKorYOUTUBE.account_idisnullfor a board over every account of a platform.statusisREADY,PENDING(STALEstill returns the earlier text) orDISABLEDfor a withheld narrative, which addserror_code:ungrounded,wrong_language,ai_unavailable,invalid_outputorbudget_exceeded.
Calendar
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.
typeisCREATE,UPDATE,CANCEL,FINDorCHECK_AVAILABILITY; the first three change the calendar.statusisAWAITING_CUSTOMER,PENDING(a person confirms it in the console),EXECUTING,COMPLETED,REJECTED,FAILEDorEXPIRED.event_idis Google's own event id andstarts_atthe proposed time; both arenullwhile the action has not reached the calendar.ai.calendar.action_updatedcarries the same fields, so an action is followed by the same event type as it moves on.
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#
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:
- Split the header on commas and read
tandv1. - Reject the delivery if
tis more than 5 minutes away from your clock. This stops an intercepted request from being replayed later. - Compute the HMAC of
t, a period and the raw request body, exactly as received. - Compare it with
v1in constant time.
Responses, retries and disabling#
- Answer with any
2xxstatus 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
statusbecomesDISABLED,failure_streakshows the count and the organization gets a notification. Fix the endpoint, then re-enable it in the console or withPATCH /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.