Skip to main content

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_LOCKED because 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_USE on 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; rejected carries the notes to fix before re-submit, and partially_rejected means one carrier declined while another verified or is still in review — correctable in place.
  • expired — the carrier’s validity window lapsed. Treated like rejected for the gating surfaces; re-submit the profile to regain approval — by clone, because an expired profile is itself frozen.
Only 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 is error.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:
Two operations are deliberately absent from the table: mutating a 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. You POST …/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, or PUT /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 returned doc_… id to the profile (POST /api/v1/compliance/compliance-profiles/:id/documents).
Fix. Read 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:
  1. You already have a suitable profile — pass its id. For a number purchase, send compliance_profile_id on 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.
  2. 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 for approved. 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:
Clone copies the profile — name suffixed (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 carries details.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:
  1. Back-off only buys anything on NOT_APPROVED. A pending_review profile needs the carrier’s clock, not yours. Poll POST …/resync on a slow loop (minutes, not seconds) and branch on the fresh status; hammering the attach call just re-reads the same 422.
  2. 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:
  1. Draft-time assembly — while you fill the profile against its country’s checklist, nothing is blocked: COMPLIANCE_PROFILE_REQUIRED answers only the surface that wants an approved reference, not the profile itself.
  2. Attach by explicit compliance_profile_id with optimistic reference — some surfaces accept a profile id and queue against pending_compliance until 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.
  3. 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.
Outside these lanes, only 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.missing array — 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_id from 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.
With those, the reviewer reads the profile’s state, its downstream references, and the exact request without a second round-trip.