Skip to content

002 — Channel Contract (ManyChat / WhatsApp) ​

Defines the wire contract between the chat platform and this service, and the latency budget every inbound request must respect.

Integration point ​

ManyChat Dynamic Block (Dev Tools). ManyChat POSTs to our HTTPS endpoint and renders the JSON we return. Dev Tools require a ManyChat Pro plan.

  • Endpoint: POST /v1/channels/manychat/message
  • Auth: shared secret in a request header, configured in the Dynamic Block UI.

Authentication and its limits ​

ManyChat does not sign its requests (no HMAC, no timestamp nonce). The only available mechanism is a static secret header that ManyChat sends verbatim.

Consequences, first accepted in ADR-0006 (superseded by ADR-0012):

  • The secret is a bearer credential. TLS is mandatory; it is the only thing preventing replay.
  • Compared with crypto.timingSafeEqual, never ===.
  • Rotatable via env without redeploying ManyChat flows (two valid secrets during a rotation window).

The secret proves the caller holds it, and nothing about which contact the request speaks for. The checks every request passes, and the controls that bound a leaked secret, are specified in 017-inbound-request-trust.md. The contact token ManyChat holds and sends back (ADR-0012) is specified in 019-contact-tokens.md.

Response contract (Dynamic Block v2) ​

jsonc
{
  "version": "v2",
  "content": {
    "messages": [/* ≤ 10 */],
    "actions": [/* ≤ 5  */],
    "quick_replies": [/* ≤ 11 — NOT on WhatsApp, see below */],
    "external_message_callback": {/* optional */},
  },
}

messages is empty only when a flow the turn sent is still playing and the reply follows it from the outbox; external_message_callback is registered all the same (030).

Channel capability matrix ​

CapabilityWhatsAppInstagramMessengerTelegram
Quick repliesnoyesyesno
Buttons / text33310
Messages10101010
Actions5555

WhatsApp is the target channel. Emitting quick_replies on WhatsApp is a silent failure — ManyChat accepts the payload and the contact never sees them. The renderer must omit the key entirely rather than send an empty array.

All URLs must be HTTPS. Media ≤ 25 MB.

Owning the conversation loop ​

The response may include external_message_callback, which registers a URL that ManyChat calls when the contact sends their next message (timeout in seconds, default and max 86400). in the payload is replaced with the contact's message text, and with the contact's token from their custom field (specs/019).

This is what keeps the conversation loop in this service rather than in ManyChat's visual flow builder, and is the reason the agent stays portable across channels. Re-register it on every turn.

Latency budget (Constitution C7) ​

ManyChat hard timeout              10 000 ms
├─ race deadline                    8 000 ms   Promise.race resolves here
└─ reserve: network, render, db     2 000 ms

model abort (safety net)           30 000 ms   AFTER the deadline, not before

The abort must fire after the race deadline, never before. Losing the race does not cancel the model call — it keeps running and delivers through the outbox, which is the entire mechanism of ADR-0001. An abort set below the deadline kills every slow turn instead, and the deferred path can never run. EnvSchema enforces the ordering, so a bad pairing fails at boot.

Race won — render the reply inline, re-register external_message_callback. A turn that sent a flow holds the response until the flow has played, never past the deadline; one that would play past it is delivered through the outbox when the flow ends (030).

Race lost — return a short acknowledgement immediately. The in-flight model call continues and writes its result to the outbox table; a worker delivers it as described below. The reply is never dropped, only deferred. When a flow the turn sent is still playing, the response carries no message instead: the flow is the holding line (030).

Deferred delivery goes through a flow, not the Send API ​

The reflexive choice is POST /fb/sending/sendContent — ManyChat's own endpoint for sending a message, one call, the obvious counterpart to the inbound webhook. It is rejected.

WhatsApp permits free-form messages only within 24 hours of the contact's last inbound message; outside that window Meta allows only pre-approved templates. This is Meta's platform policy, not a ManyChat limitation. sendContent enforces it by refusing the send:

jsonc
{
  "status": "error",
  "message": "Content can’t be sent to the subscriber without a message tag. Subscriber’s last interaction was over 46h ago (more than 24 hours ago)",
  "code": 3011,
}

The escape hatch that used to exist — message_tag — is gone: ManyChat now rejects the field outright ("Message tags are no longer supported"). So sendContent has no way to reach a contact who has gone quiet, and a reply the agent has already generated and charged for is undeliverable.

Delivery is therefore two calls: write the reply text to a per-subscriber custom field, then trigger a flow whose message body renders that field.

StepEndpointCarries
1POST /fb/subscriber/setCustomFieldByNamesubscriber_id, field name, reply text
2POST /fb/sending/sendFlowsubscriber_id, flow_ns

A flow may contain a template, which is what lets it leave the 24-hour window. Because the message body is a variable rather than literal text, the flow delivers model output that did not exist when the flow was authored.

Verified against a live account on 2026-09-15: a contact whose last interaction was 640 hours earlier — unreachable by sendContent — received the text through this path.

The reply field is per-subscriber, never a bot field ​

ManyChat exposes two kinds of variable, and only one is safe here.

Bot fieldCustom field
Endpoint/fb/page/setBotFieldByName/fb/subscriber/setCustomFieldByName
Scopethe whole accountone contact
Takes subscriber_idnoyes

A bot field holds one value for the entire account. Two conversations in flight would overwrite each other between step 1 and step 2, and the contact whose flow fires second receives the other contact's reply. That is a disclosure of one customer's conversation to another, so it violates Constitution C5 rather than merely producing a wrong answer.

The absence of subscriber_id in the bot-field endpoint is the tell: an API that is not told who the value belongs to cannot be storing it per contact.

The two calls are one delivery ​

Steps 1 and 2 are not atomic. A failure between them leaves the field set and nothing sent; a retry that assumes the field survived may send whatever the field holds now, which after an intervening turn is the wrong reply.

Therefore: the field is re-set immediately before every trigger, including every retry. The outbox row is the only durable record of what should be sent; the custom field is scratch space and is never read back as a source of truth.

A partial delivery counts as a failed attempt and re-enters the retry schedule whole. Retry and dead-lettering are unchanged — see ADR-0004.

Inbound payload ​

ManyChat sends the fields configured in the Dynamic Block UI. We require at minimum a stable subscriber identifier and the message text. The body also carries "ai_token": "", the contact's token, without which a request reads none of the contact's history (specs/019). A tenant with a course field adds "course": "", rendered from that field (specs/028): it is optional, and an empty, unrendered or unknown value is treated as no course rather than refused. The reply's callback asks ManyChat for both. Inbound schemas are .strict(): unknown keys are rejected rather than silently ignored, so a ManyChat-side change surfaces as a 400 instead of as degraded behavior.

Verification ​

The channel port is stubbed by the outbox suite, so no test there sees the wire format. These are the checks that would actually catch a violation:

  1. A unit test over fetchImpl pins both request bodies: the custom-field write carries subscriber_id, and the flow trigger carries subscriber_id and flow_ns. This is the check that a stale field like message_tag defeated for as long as nothing asserted the bytes.
  2. A test asserts the field write targets /fb/subscriber/… and not/fb/page/…. A path-level assertion is used deliberately: the two endpoints accept near-identical bodies, so only the URL distinguishes a per-contact write from an account-global one.
  3. A test drives two subscribers through one worker batch and asserts each trigger was preceded by a field write carrying that subscriber's text. This is what fails if the implementation ever hoists the field write out of the per-row loop.
  4. A test asserts a retry re-writes the field rather than only re-triggering.

What this misses: none of it proves WhatsApp delivered anything. ManyChat returns {"status":"success"} when it accepts a trigger, and acceptance is not delivery — a misconfigured flow, an unapproved template or a variable pointing at the wrong field all return success and send nothing useful. Only a human reading a phone closes that gap, and no test in this repository can.

Equally: the flow and the custom field live in the tenant's ManyChat account, not in this repository. Renaming either breaks delivery at runtime with no failing test anywhere. The runtime symptom is a dead-lettered outbox row whose last_error names the rejected field or flow.