Compliance profile lifecycle errors
A compliance profile (cprof_…) is the reusable KYC packet that every
gated surface — number purchases, Sender-ID registrations, brands, and
campaigns — draws from instead of re-asking for the same paperwork. A small
set of error codes guards that packet across its lifecycle: one refuses a
submit of an incomplete packet, two block a surface that references a
profile, and two block an operation on the profile itself. This page maps
each code to the state that raises it, distinguishes the two 409 conflicts
that look identical from the outside, and names the fix you can run
yourself.
Compliance posture is tenant-owned: you create the profile, submit
it, and decide what it references. Orbit enforces the lifecycle
gates; the approval itself always comes from the carrier or
regulator reviewing your packet. Neither a rejection nor a lock is a
platform mandate — it is a state the packet or one of its references
is in, and the path past it is always in your hands.
The lifecycle state map
A profile moves through one fixed review lifecycle, with three side-exits:- draft — freely editable. Nothing submits to a carrier until you submit the profile, so fields, documents, and metadata may change.
- pending_review — submitted and awaiting carrier or regulator review. The payload freezes: downstream submissions carry a copy of what we sent, and edits to the live packet could fork it from what each carrier holds.
- approved — review cleared; the profile may open gated surfaces. Still frozen for mutation — once carriers act on the submitted packet, edits would fork our record from theirs.
- locked (not a status — a guard) — any mutation on a non-editable
status answers
409 COMPLIANCE_PROFILE_LOCKEDbecause the payload entered a downstream submission. Fix is clone-and-fix, below. - in use (a status-independent guard) — any asset (number
purchase, sender, brand, or campaign) still referencing the profile
answers
409 COMPLIANCE_PROFILE_IN_USEon delete or document-detach, because deletion would strand the downstream reference. - rejected / partially_rejected — the carrier refused the packet
(or some of it). Editable again;
rejectedcarries the notes to fix before re-submit, andpartially_rejectedmeans one carrier declined while another verified or is still in review — correctable in place. - expired — the carrier’s validity window lapsed. Treated like
rejectedfor the gating surfaces; re-submit the profile to regain approval — by clone, because anexpiredprofile is itself frozen.
draft, rejected, and partially_rejected are editable;
everything else is locked against mutation by design. The editability
gate and the UI’s edit affordance share one status set — if the
dashboard offers an edit, the API accepts it; a 409 from an edit the
dashboard offered means the profile changed status between render and
write (see In-flight races, below).
The statuses gate surfaces in one direction only — a profile governs
what it may be attached to, but every surface’s own gate (number
pending_compliance, sender pending) is documented on
Troubleshoot a pending number or Sender ID.
The two 409 conflicts — LOCKED vs IN_USE
Both conflicts answer HTTP 409 with the same envelope shape, so the only reliable discriminator iserror.code — never the status, never
the message text:
The two guards are independent and either can fire first: mutating a
locked profile that is also in use answers LOCKED (the status guard
runs before any reference check), and deleting an editable profile that
is referenced answers IN_USE. A single profile can be both at once —
locked
approved and referenced by live numbers — in which case the
clone and the detach walk are both owed before the old profile can go
away.
There is one ordering note for scripts: LOCKED guards fire on the
profile’s stored status at the moment of the write, so a transition
that landed between your GET and your PATCH (reviewer approved the
packet in the meantime) flips an editable profile into a locked one
mid-sequence. That race is covered below.
Map your symptom to the failing operation
Before you read the individual codes, sort the failure by which call you made:The transition map — what each write is allowed to do
Every lifecycle move is one of five API calls. Legal transitions succeed; everything else answers one of the codes above. The edges you can drive:
One curl per legal transition, run against a real profile id:
pending_review or approved profile (409 LOCKED — by design, the
carrier holds a copy) and force-transitioning a status the carrier
owns. Review outcomes arrive through resync or webhook-driven
updates, never through a direct status write.
In-flight races — when two operations collide
Two races produce most of the “the API lied to me” reports on this surface. Both resolve the same way: the second writer to arrive loses, and the loser’s error names the state the first writer created. 1. Document attach racing a submit. YouPOST …/documents to add
an address proof while, in another tab or another process, the profile
is submitted. If the submit lands first, the attach answers
409 COMPLIANCE_PROFILE_LOCKED — the packet already fanned out, and an
attach would fork the carrier’s copy from ours. If the attach lands
first, it is part of the submitted packet (order is not guaranteed
across concurrent callers). Rule: finish every mutation, then
submit — or, if the submit already won, clone and attach on the clone.
Check the profile’s status, not your own sequencing assumptions.
2. Resync racing a reviewer decision. You poll
POST …/resync while the carrier flips the packet to approved. The
resync is read-only against the profile — it never fails — but the
transition it reports changes what your next write may do: a
PUT …/data you queued expecting rejected (editable) now hits
approved (locked) and answers 409 COMPLIANCE_PROFILE_LOCKED. The
mirror race — an edit you wrote against pending_review that arrives
after a rejection — succeeds, because rejected is editable.
Rule: re-GET /compliance-profiles/:id after any resync and
branch on the fresh status; never assume a polled status survives to
the next write.
In both races the 409 is the guard doing its job, not a fault — and in
both the fix is the same sequence: read the profile, then act on the
status you actually hold.
The five codes
COMPLIANCE_PROFILE_INCOMPLETE — 422
Where it fires. POST /api/v1/compliance/compliance-profiles/:id/submit — the call that sends
a draft to the carriers for review. This is a submit-time gate:
nothing about the profile’s downstream references causes it.
Why. Before Orbit fans the packet out, it grades the profile against
the country’s RequirementSet for its use_case — the same checklist the
reviewer will run. Anything the checklist marks required and the packet
does not hold wins you this 422 instead of a wasted review cycle. The
response’s details.missing is the checklist, named exactly:
data.<key>— a structured field the RequirementSet requires is empty (set it on the profile’s data page, orPUT /api/v1/compliance/compliance-profiles/:id/data).document.<role>— a required document role (business_doc,address_proof,id_proof,authorization) has no document attached in that role. Upload to your library (POST /api/v1/compliance/documents), then attach the returneddoc_…id to the profile (POST /api/v1/compliance/compliance-profiles/:id/documents).
details.missing, fill each named item on the draft
(the draft is still freely editable — nothing has submitted yet), and
submit again. Check the destination market’s packet on
Country Compliance Requirements
before you refill blindly, and run
regulatory preview to settle exactly
which fields and documents a country asks for.
Never retry. Not a transient fault — an unchanged resubmit answers
the same 422 with the same missing list, forever. Only a packet whose
named gaps are closed clears this gate.
COMPLIANCE_PROFILE_REQUIRED — 422
Where it fires. A purchase or registration call on a surface that
requires a profile: a number purchase into a regulated country (UK,
DE, FR, and similar), a DIDWW DID-group placeholder row, a sender
registration, a brand or campaign registration that requires KYC.
Why. The surface checked your account for an approved profile
covering that use case and country and found none — either because no
profile exists yet, or because the existing ones are draft,
pending_review, rejected, or expired.
Fix. Two paths:
- You already have a suitable profile — pass its id. For a
number purchase, send
compliance_profile_idon the purchase request; for a sender or brand registration, attach the profile from its console surface (Settings → Compliance → Sender IDs / Brand identity). Some surfaces pick up the only approved profile automatically; the explicit id is always safer. - You do not — create a profile (Settings → Compliance →
Profiles, or
POST /api/v1/compliance/compliance-profiles), fill its fields against the country’s checklist (see Country Compliance Requirements), submit it, and wait forapproved. Only then re-run the blocked purchase.
COMPLIANCE_PROFILE_NOT_APPROVED — 422
Where it fires. The same attach path as above, when the request
references a profile whose status is not approved.
Why. The gate reads the profile’s status: draft,
pending_review, rejected, partially_rejected, or expired all
fail identical to a missing profile. A pending_review retry loop
does nothing but burn lead time.
Fix. Read the profile’s status first (the Profiles list in the
dashboard, or GET /api/v1/compliance/compliance-profiles/:id).
- pending_review — wait for the carrier’s review to complete;
attach succeeds the moment it flips to
approved. See the lead times on Troubleshoot a pending number or Sender ID. - rejected / partially_rejected — read the reviewer notes on the
Profiles page, fix what they flagged on the still-editable draft,
and re-submit. A re-submit moves the profile back to
pending_review; only an approved packet clears this gate. - expired — treat as rejected for gating, but cloned rather than edited in place (see LOCKED below for why clone is the path once a profile is frozen).
COMPLIANCE_PROFILE_LOCKED — 409
Where it fires. Any attempt to mutate a profile that has moved out
of the editable set: editing structured fields or metadata (PATCH),
bulk-setting data (PUT …/data), updating the use-case or country,
attaching or replacing documents, or re-submitting an approved /
pending_review / expired profile.
Why. Once a profile submits to providers, its payload freezes so
the copy carriers hold and the copy Orbit reads stay in sync — an edit
to a post-submit profile would fork those copies. draft,
rejected, and partially_rejected stay editable precisely because
nothing depends on them yet; everything else is locked by design.
Fix. Clone-and-fix:
(copy), structured data,
and document attachments — into a new draft with a new id and no
provider submissions yet. Edit the clone, submit it, and re-attach it
to every downstream asset the old profile fed (with
POST /api/v1/numbers/:id/attach-compliance-profile for numbers, or
the sender-ID / brand surfaces’ attach endpoints). This is also the
path for expired profiles once you need to re-verify them.
COMPLIANCE_PROFILE_IN_USE — 409
Where it fires. DELETE on the profile, or a document delete on
one of its underlying documents, when any active downstream entity
still references the profile — a purchased number, a registered sender
ID, a brand, or a campaign. The check counts both the platform number
inventory (available / assigned) and your tenant’s live
(non-released) numbers, so a bound profile can never be deleted out
from under a number.
Why. Deletion would strand the downstream asset without its KYC
backing, so the API refuses and the number pages keep their reference.
Fix. Detach downstream first, then retry the delete. Walk your
numbers (GET /api/v1/compliance/compliance-profiles/:id/numbers
lists every bound DID with its current status — use it as the detach
checklist), sender IDs, brands, and campaigns; for each asset
referencing the profile, either attach a different approved profile or
retire the asset. The conflict check runs server-side, so run the
detach walk from the console surfaces each asset exposes. Only when
nothing references the profile does the delete (or the document
delete) succeed.
Retry and back-off policy per code
None of the five codes is a transient transport fault and none carriesdetails.retry_after — the 409-class conflicts resolve by state, not
by waiting, and the 422-class gates resolve by fixing the packet or
its reference. Classify before you loop:
Two practical consequences:
- Back-off only buys anything on NOT_APPROVED. A
pending_reviewprofile needs the carrier’s clock, not yours. PollPOST …/resyncon a slow loop (minutes, not seconds) and branch on the fresh status; hammering the attach call just re-reads the same 422. - A 409 that persists after its fix is a desync, not a gate. If LOCKED survives the clone path or IN_USE survives a completed detach walk, re-read the profile and its bound numbers — the state the error names is the state the server holds, so a persistent 409 means a transition landed (or a reference exists) that your client has not seen. The escalate payload below closes that loop.
When a non-approved profile is acceptable
Three lawful uses survive the NOT_APPROVED gate:- Draft-time assembly — while you fill the profile against its
country’s checklist, nothing is blocked:
COMPLIANCE_PROFILE_REQUIREDanswers only the surface that wants an approved reference, not the profile itself. - Attach by explicit
compliance_profile_idwith optimistic reference — some surfaces accept a profile id and queue againstpending_complianceuntil approval lands (see the pending-gated runbook for the numbers flow). Referencing a non-approved profile goes through as long as the surface supports queuing; the 422 fires when the surface requires an approved profile now. - Clone-and-fix cycles — editing the clone and re-submitting replaces the old profile gradually; the old asset keeps serving traffic until you re-attach, so an asset mid-swap is not an approved profile failure.
approved opens the gate.
Escalate with the full payload
If a profile-side error persists past the fix above, open a support ticket carrying:- The profile id (
cprof_…), its current status, and the operation you attempted (submit / attach / mutate / delete). - The error code and HTTP status from the response envelope.
- For INCOMPLETE: the full
details.missingarray — the reviewer compares it against the country’s RequirementSet without a lookup. - The referencing asset id (
num_…, sender id, brand id, or campaign id) for REQUIRED / NOT_APPROVED / IN_USE. - The
meta.request_idfrom the failed response — the durable handle support uses to pull the attempt directly. - For LOCKED: the id of the clone you created and its own status.
Related references
- Troubleshoot a pending number or Sender ID — the status vocabulary, pending-gated surfaces, and re-submission path this page plugs into.
- Assemble a Shared Compliance Profile Across Gated Surfaces — the lifecycle walkthrough: reuse, attach-by-id, and rotation without downtime.
- KYC Documents & the Compliance-Profile Lifecycle — field-by-field packet anatomy the transition map above moves through.
- Country Compliance Requirements — the per-market checklist a profile grades against before any of this.
- Compliance send-gate error codes — the routing surface these codes join when a send refuses before dispatch.
- Compliance profiles API — the endpoint surface, including the clone and bound-numbers endpoints the LOCKED and IN_USE fixes drive.