Activating a Messaging Channel
What this skill does
Given a
, reads the channel's
, then fires
PATCH /services/data/v{V}/sobjects/MessagingChannelUsage/{MCU_ID}
with body
{"DeploymentStatus":"Provisioning"}
. The server-side chain:
MessagingChannelUsageFunctions.validateBeforeSave
runs → validateChannelReadinessOnProvisioning
. For WhatsApp this confirms consent is configured. Rejected writes return HTTP 400 FIELD_INTEGRITY_EXCEPTION
.
MessagingChannelUsageFunctions.saveHook_PostStmtExecuteOnce
fires unconditionally after the UPDATE statement. It calls MessagingChannelUsageFunctionsHelper.handlePostSave
which registers a post-commit .
- At commit, the observer calls
ConversationChannelUsageDeploymentStatusService.handleProvisioning
(inherited from AbstractChannelUsageDeploymentStatusService
), which:
- Calls — -dispatches by for the external callout:
- →
registerCsotWhatsAppNumber
→ LiveMessageSetupApi.registerWhatsAppNumber
→ Meta + status verification. 15-21s wall-clock.
- →
metaGraphApiService.subscribeFacebookPage
.
- → .
- , , everything else → branch, no external callout, no network wait.
- On success: writes
DeploymentStatus = 'Active'
via PLSQL.
- On failure: writes
DeploymentStatus = 'Error'
plus / .
- Inside the same observer, a second pass syncs
MessagingChannel.IsActive = true
once MCU reaches (the flag skips the flip while status is still ).
All synchronous within the PATCH request — the 204 response only comes back after the full chain completes. WhatsApp: ~15-21s (Meta
round-trip). Apple / Line: ~1s (no external call; just the local save-hook + observer + PLSQL write). Verified on wadtesting 2026-04-30.
Reference File Index
| Reference file | Load when |
|---|
references/phone-verification.md
| Stage 3 comes back with ErrorReason === "VERIFICATION_REQUIRED"
(WhatsApp only) — the phone-number OTP verification sub-flow. |
references/worked-examples.md
| You want a reference run of the WhatsApp happy-path, Apple activation, a readiness failure, or the already-active no-op. |
| Troubleshooting an unexpected result, or before modifying this skill — the eleven known gotchas. |
Why REST PATCH instead of Apex?
A direct REST PATCH produces the identical save-hook chain as the old
Apex method, with substantially less machinery — no CSRF cookie acquisition, no bootstrap fetch, no Aura response parsing, no double-wrapped
. REST semantics are honest: 204 means the transition succeeded; 4xx means it didn't.
Code proof:
MessagingChannelUsageFunctions.saveHook_PostStmtExecuteOnce
fires on any DML path (REST, SOAP, Apex, Metadata API) — there is no Apex-specific gate. The entity XML (
MessagingChannelUsage.entity.xml
) marks
as
with no
attribute. The transition validator (
getValidAPIStatusTransitions
) allows
(and
, and
Error → Provisioning | Deprovisioning
, and
). The DB-only transitions
Provisioning → Active | Error
are reserved for the observer's PLSQL call — that's why we write
and let the server pick the terminal state.
When NOT to use this skill
- The channel is already . Re-firing is blocked by the API transition validator ( is not in
getValidAPIStatusTransitions()
) — the PATCH would return 400. The Stage 1 precondition check catches this and emits .
- Routing isn't configured. used to fail with
LiveMessageSetupException
/ at the Apex entry point. With the REST path the same guard lives in validateChannelReadinessOnProvisioning
— write with SessionHandlerId=null && FallbackQueueId=null
→ 400 FIELD_INTEGRITY_EXCEPTION
. Run service-de-channel-routing-configure
first. The Stage 1 check still runs defensively.
- The MCU doesn't exist. Can't PATCH a row that's missing. Run the insertion skill first — it always creates the MCU as a side-effect of .
Inputs (from caller)
- — a 15- or 18-char (prefix ). The channel must already exist with a non-null or .
- — optional; the CLI target-org alias. Default: whatever returns. Used for OAuth and SOQL reads.
- — optional; REST API version. Default: . Any version where is addressable as a standard sobject is fine (v50+ should work; not exhaustively tested).
Unlike the old Aura-based version of this skill, there are no
/
inputs — the PATCH is synchronous end-to-end.
Output (to caller)
Success — channel is live:
json
{"ok": true, "channelId": "0Mj...", "mcuId": "0gL...", "deploymentStatus": "Active", "isActive": true, "messageType": "WhatsApp", "durationMs": 20934}
Success — no-op (already active):
json
{"ok": true, "noop": true, "channelId": "0Mj...", "message": "Channel already active"}
Failure — precondition not met:
json
{"ok": false, "kind": "no-routing", "hint": "run service-de-channel-routing-configure first"}
{"ok": false, "kind": "no-mcu", "hint": "no MessagingChannelUsage row for this channel — run the insertion skill first"}
{"ok": false, "kind": "channel-missing","hint": "MessagingChannel id not found"}
Failure — validator or server-side provisioning error:
json
{"ok": false, "kind": "readiness-failed", "errorCode": "FIELD_INTEGRITY_EXCEPTION",
"message": "...", "hint": "validateChannelReadinessOnProvisioning rejected the write — most commonly missing consent; run service-de-channel-consent-configure first"}
{"ok": false, "kind": "provisioning-error", "mcuId": "0gL...", "errorReason": "MetaRegistrationFailed", "errorDetails": "..."}
{"ok": false, "kind": "verification-failed", "hint": "WhatsApp phone number verification failed or was declined by user"}
{"ok": false, "kind": "verification-request-failed", "errorCode": "...", "message": "...", "hint": "Could not request verification code from Meta"}
Failure — auth / transport:
json
{"ok": false, "kind": "auth", "hint": "OAuth token invalid / expired — run 'sf org login web'"}
{"ok": false, "kind": "transport", "status": 500, "message": "..."}
Stage 1: Precondition checks
Read the channel, then its MCU, as two separate SOQL calls. A combined subquery would be cheaper but fails on orgs where the child relationship is unnameable (see gotcha #4).
bash
sf data query --target-org '{ORG_ALIAS}' \
--query "SELECT Id, DeveloperName, MessageType, IsActive, SessionHandlerId, FallbackQueueId, MessagingPlatformKey FROM MessagingChannel WHERE Id = '{CHANNEL_ID}'" \
--json > /tmp/amc-precheck-channel.json
sf data query --target-org '{ORG_ALIAS}' \
--query "SELECT Id, DeploymentStatus, DeploymentType, ErrorReason, ErrorDetails FROM MessagingChannelUsage WHERE MessagingChannelId = '{CHANNEL_ID}'" \
--json > /tmp/amc-precheck-mcu.json
Let
channel = /tmp/amc-precheck-channel.json records[0]
and
mcu = /tmp/amc-precheck-mcu.json records[0]
:
| Condition | Envelope |
|---|
| Channel query returned 0 records | {ok:false, kind:"channel-missing", hint:"MessagingChannel id not found"}
|
channel.IsActive === true
| {ok:true, noop:true, channelId, message:"Channel already active"}
— return |
channel.SessionHandlerId == null && channel.FallbackQueueId == null
| {ok:false, kind:"no-routing", hint:"run service-de-channel-routing-configure first"}
|
| MCU query returned 0 records | {ok:false, kind:"no-mcu", hint:"no MessagingChannelUsage row for this channel — run the insertion skill first"}
|
| Otherwise | Record , {MESSAGE_TYPE} = channel.MessageType
, {MESSAGING_PLATFORM_KEY} = channel.MessagingPlatformKey
, {INITIAL_MCU_STATUS} = mcu.DeploymentStatus
and continue to Stage 2. |
Also record
(epoch ms at start of Stage 2) so the final envelope can report
.
Stage 1.1: Fast path for already-provisioning MCU
If
{INITIAL_MCU_STATUS} === "Provisioning"
— the MCU is already mid-flight from a prior call in this transaction window. Skip Stage 2 (firing the PATCH) entirely and jump to Stage 3 (verification). This is a rare race guard: the observer is synchronous with the PATCH, so by the time the caller sees the 204 the status is already terminal (
or
) —
should be invisible from outside. If we do see it in the precheck, something wrote
in a separate DML and the observer is still mid-flight — don't fire a second PATCH.
Stage 2: PATCH MessagingChannelUsage.DeploymentStatus = "Provisioning"
Use
so authentication stays inside the CLI's transport — no OAuth token is ever extracted into shell state.
Fire the PATCH.
This call can take 15-30 seconds for WhatsApp — the observer runs synchronously, including Meta's
round trip.
has no separate client-side timeout to raise; it waits on the underlying HTTP call.
bash
sf api request rest \
"/services/data/v{API_VERSION}/sobjects/MessagingChannelUsage/{MCU_ID}" \
--method PATCH \
--target-org '{ORG_ALIAS}' \
--header 'Content-Type: application/json' \
--body '{"DeploymentStatus":"Provisioning"}' \
--include \
> /tmp/amc-patch-response.txt 2>&1
HTTP_CODE=$(head -1 /tmp/amc-patch-response.txt | grep -oE '[0-9]{3}')
prints the HTTP status/headers block ahead of the (typically empty, on 204) body — read the status line from that block rather than a
-style trailing marker.
Classify by HTTP status:
| Status | Body | Handling |
|---|
| 204 | empty | Success. The observer ran to completion; MCU is or . Continue to Stage 3 to read the terminal state. |
| 400 | [{errorCode:"FIELD_INTEGRITY_EXCEPTION", message:"..."}]
| Validator rejection. See table below. |
| 400 | [{errorCode:"INVALID_FIELD_FOR_INSERT_UPDATE" or "MALFORMED_ID", ...}]
| Skill bug — the MCU_ID from Stage 1 was wrong, or the body shape is off. Emit {ok:false, kind:"transport", status:400, message}
. |
| 401 | (usually empty) | Bearer token invalid. Emit {ok:false, kind:"auth", hint:"OAuth token invalid / expired — run 'sf org login web'"}
. |
| 403 | [{errorCode:"INSUFFICIENT_ACCESS"}]
| User lacks perm to write . Emit {ok:false, kind:"business", errorCode:"INSUFFICIENT_ACCESS", message}
. |
| 5xx | varies | Transport. The observer may have partially committed — Stage 3's SOQL is the source of truth. Read MCU state; if it's , report success with a warning; if or still , classify as {ok:false, kind:"transport", status, message}
. |
Known 400 FIELD_INTEGRITY_EXCEPTION
messages:
| Message fragment | Meaning | Envelope |
|---|
| "invalid deployment status transition" | The current status doesn't allow (e.g. MCU is already — Stage 1 should have caught this, but there's a race window). | Re-read MCU; if now , emit success-noop. If , Stage 3 poll. Otherwise emit {ok:false, kind:"readiness-failed", ...}
. |
| "consent" / "keyword" / mentions of STOP/HELP | validateChannelReadinessOnProvisioning
rejected — channel doesn't have required consent configured. | {ok:false, kind:"readiness-failed", errorCode:"FIELD_INTEGRITY_EXCEPTION", message, hint:"channel requires a ConsentType and a matching MsgChannelLanguageKeyword record (opt-out keyword + confirmation) before activation — run service-de-channel-consent-configure"}
. |
| "routing" / "queue" / "SessionHandler" | Routing precondition (Stage 1 should have caught, but the validator re-checks). | {ok:false, kind:"no-routing", message, hint:"run service-de-channel-routing-configure first"}
. |
| other | Unrecognized validator error. | {ok:false, kind:"readiness-failed", errorCode, message}
. |
Stage 3: Read the terminal MCU state
The PATCH is synchronous, so by the time we're here the MCU is
or
— no polling. Read once:
bash
sf data query --target-org '{ORG_ALIAS}' \
--query "SELECT Id, DeploymentStatus, ErrorReason, ErrorDetails FROM MessagingChannelUsage WHERE Id = '{MCU_ID}'" \
--json > /tmp/amc-poststate-mcu.json
| Status | Handling |
|---|
| Continue to Stage 4. |
with ErrorReason === "VERIFICATION_REQUIRED"
| For WhatsApp channels only: phone number needs OTP verification. Continue to Stage 3.2 (WhatsApp verification flow). |
| (other) | Emit {ok:false, kind:"provisioning-error", mcuId, errorReason, errorDetails}
. |
| Unexpected — the observer was supposed to terminate before the PATCH returned 204. Fall through to a defensive poll (see Stage 3.1). |
| The PATCH returned 204 but the write didn't take? Emit {ok:false, kind:"transport", message:"PATCH returned 204 but MCU is still Disabled — observer didn't commit"}
. |
Stage 3.2: WhatsApp phone number verification (only if ErrorReason === "VERIFICATION_REQUIRED")
This sub-flow only runs for WhatsApp channels when activation fails with
ErrorReason === "VERIFICATION_REQUIRED"
— the phone number needs OTP verification with Meta before it can be registered.
If the MCU comes back with ErrorReason === "VERIFICATION_REQUIRED"
(WhatsApp only), load references/phone-verification.md
and follow it to drive the phone-number verification sub-flow (request code → prompt user → validate code → retry activation once).
Stage 3.1: Defensive poll (only if Stage 3 saw )
The observer's external-callout block (WhatsApp/Facebook/SMS) runs synchronously inside the PATCH request — there's no async queue indirection in
for any message type (verified against the switch/case in
ConversationChannelUsageDeploymentStatusService
). So a
status at Stage 3 should not happen in steady state. Possible causes if it does: an exception thrown
after the external call succeeded but before the PLSQL terminal-write ran; unusual instance config with async observer execution; or a future MessageType whose dispatch behavior we haven't accounted for. If Stage 3 returned
, loop:
bash
for i in 1 2 3 4 5 6 7 8 9 10; do
sleep 3
STATUS=$(sf data query --target-org '{ORG_ALIAS}' \
--query "SELECT DeploymentStatus FROM MessagingChannelUsage WHERE Id='{MCU_ID}'" --json \
| node -e 'console.log(JSON.parse(require("fs").readFileSync(0,"utf8")).result.records[0].DeploymentStatus)')
case "$STATUS" in
Provisioning) continue ;;
Active|Error) break ;;
esac
done
Max wait: 30s. If still
after the loop: emit
{ok:false, kind:"timeout", mcuId, lastStatus:"Provisioning", elapsedS:30}
. Verified on WhatsApp/wadtesting 2026-04-30: this loop should never actually iterate.
Stage 4: Verify MessagingChannel.IsActive
The observer flips
inside the same callback as the status terminal write. In principle this is set by the time the PATCH returns. Confirm:
bash
sf data query --target-org '{ORG_ALIAS}' \
--query "SELECT Id, IsActive FROM MessagingChannel WHERE Id = '{CHANNEL_ID}'" --json > /tmp/amc-verify-channel.json
If
: compute
durationMs = Date.now() - T0
and emit success. If
despite MCU
: the
sync pass skipped (e.g.
was true when the observer ran, which shouldn't happen post-terminal). Emit:
json
{"ok": false, "kind": "provisioning-error", "mcuId": "...", "errorReason": "mcu-active-but-channel-inactive",
"errorDetails": "MCU DeploymentStatus=Active but MessagingChannel.IsActive=false — observer's IsActive sync pass didn't run. Inspect MessagingChannelUsageFunctionsHelper.handlePostSave."}
Stage 5: Report to caller
Build the success envelope:
json
{"ok": true, "channelId": "{CHANNEL_ID}", "mcuId": "{MCU_ID}", "deploymentStatus": "Active",
"isActive": true, "messageType": "{MESSAGE_TYPE}", "durationMs": 20934}
If this skill is the leaf (user invoked it directly), render:
Success — Activated — MessagingChannel {CHANNEL_ID} ({messageType}) is now live. MCU DeploymentStatus=Active, IsActive=true. (~{durationMs/1000}s)
Info: Already active — MessagingChannel {CHANNEL_ID} is IsActive=true. No changes.
(no-op path)
Error: Routing not configured — run 'service-de-channel-routing-configure' skill first.
(no-routing)
Error: No MessagingChannelUsage row — run the insertion skill first.
(no-mcu)
Error: Readiness check failed: {message} — if it mentions consent/keywords, run 'service-de-channel-consent-configure' first.
(readiness-failed)
Error: Activation failed: {errorReason} — {errorDetails}
(provisioning-error)
Error: WhatsApp phone number verification failed: {hint}
(verification-failed)
Error: Could not request verification code: {message}
(verification-request-failed)
Timeout: Activation timed out after {elapsedS}s with MCU.DeploymentStatus={lastStatus}. Defensive-poll limit hit; this is unusual. Check MCU {mcuId} in Setup.
(timeout)
Worked examples
For end-to-end activation traces (WhatsApp happy-path, Apple activation, a readiness-validator failure, and the already-active no-op), see
references/worked-examples.md
.
Gotchas
Eleven known gotchas — synchronous PATCH timing for WhatsApp, valid API status transitions, the
sync pass, relationship-name variance across orgs, consent preconditions,
picklist casing, the REST-vs-Apex equivalence,
values,
handling, OAuth token extraction, and the Status-code-409 Admin API conflict. Before troubleshooting an unexpected result or modifying this skill, load
and follow it.