Nexus API Reference
Nexus API reference
Nexus · API Reference
v1.0
WeGo Send · Sawego Digital

API Reference

Nexus executes agent-initiated mobile-money deposits. It accepts operation orders from n8n, queues them per Android device, dials USSD on the assigned SIM, correlates operator SMS as proof, and delivers a signed webhook on every terminal state.

Base URL
https://api.nexus.sawego.com/v1
Local dev
127.0.0.1:8000/v1
Amounts
Integer XAF
Timestamps
ISO 8601 + TZ
Authentication

Three credential schemes. The board token carries a role, so a plain agent and an admin reach different routes with the same header. All unauthenticated requests return 401.

CallerHeaderScope
n8nX-API-Key: <key>/operations, /webhooks/inbound
Android deviceAuthorization: Bearer <device_token>/device/* only
Nexus BoardAuthorization: Bearer <session_token>POST /operations, GET /operations (own only), /agents/*, /devices/*, /sims/*
Nexus Board, admin roleAuthorization: Bearer <session_token>/admin/*, /agents/admin*, every agent's operations, requires the admin role

Session tokens are issued by POST /agents/login and are valid for 5 hours. They are stored in Redis as the live gate, with a permanent record in the Supabase sessions table. Device tokens are issued at registration and revocable per device.

A Board agent can read, cancel, or delete only operations attributed to its own session and may read its own profile, access grant, and assigned SIM balance summaries. Device and SIM inventory or provisioning require the admin role. Responses outside the device dispatch route never contain a SIM PIN.

Getting credentials
X-API-Key

Shared secret held by n8n and admin scripts. Not self-service: request one from a Nexus administrator, who sets it as the NEXUS_API_KEY environment variable and shares it over a secure channel.

Board session token

Call POST /agents/login with an agent login and password. New agent accounts are created by an admin via POST /agents/admin. The token expires after 5 hours; log in again to refresh.

Device token

Returned once from POST /devices when a Board admin registers a new Android device. It cannot be retrieved again after creation, so store it in the device's secure storage immediately.

Errors

Every error has the same body. detail is written for the agent reading it on the board, code is stable for anything that branches on the refusal, and context carries the ids and values a developer needs.

json

  "detail" "This transfer is settled and can no longer be cancelled."
  "code" "operation_not_cancellable"
  "context" "operation_id" "3f2c0000-0000-4000-8000-000000000001" "operation_status" "confirmed"

Validation errors keep the list FastAPI produces as detail, with code set to invalid_request. The receipt routes keep their {"reason", "message"} detail and add code beside it. A route that names no code of its own gets one from its status. Error codes lists every code.

The X-Correlation-Id response header names the request in the logs.

CodeMeaningDefault code
400Bad request bodybad_request
401Missing or invalid credentialsnot_signed_in
403Signed in, but not allowednot_allowed
404Resource not foundnot_found
409Conflict, such as a duplicate request_idconflict
422Validation error or business rule refusalrefused, or invalid_request for validation
423Circuit breaker activelocked
429Too many requests; retry after the Retry-After headerrate_limited
500Internal server errorserver_error
Operations
POST/operations

Create an operation. Called by n8n after all business gates pass, or by an agent from the board.

X-API-Key or a board session token

With X-API-Key, agent_id in the body names the agent the operation belongs to, which is how n8n has always called it. With a board token, agent_id is taken from the token and any value in the body is ignored: a caller controls the body and could otherwise attribute its own transfer to somebody else.

Request body
json

  "request_id" "wego-20260807-000412"
  "parent_operation_id" 
  "agent_id" "3f2c0000-0000-4000-8000-000000000001"
  "operator" "mtn"
  "client" 
    "msisdn" "670000528"
    "nom" "Awa N."
  
  "type_op" "deposit"
  "montant" 25000
  "merchant_code" "000123"
  "contact_id" "contact-000001"
  "wego_verified" 
  "sim_id" "sim-000001"
  "origin" "aggregated"
FieldTypeRequiredDescription
request_idstringyesIdempotency key. Generated by the Notion Board. One execution per value.
parent_operation_idUUID or nullnoSet when this is a manual replay of a prior operation.
agent_idUUIDyesResolved by n8n from the Notion click context.
operatorstringyesTarget operator: mtn or orange. Server selects the best available SIM automatically.
clientobjectyesRecipient details for an operation.
client.msisdnstringyesDestination MSISDN (9-digit Cameroonian local format).
client.nomstringyesClient display name.
type_opstringnodeposit, withdrawal, balance, float_deposit, or mini_statement. Defaults to deposit.
montantintegeryesAmount in FCFA (integer). Above 0
merchant_codestring or nullnoRequired for Orange withdrawal, float_deposit, and mini_statement.
contact_idstring or nullnoOpaque to Nexus. Stored at creation and returned on read, so n8n can route the evidence onward. Never interpreted or validated.
wego_verifiedbooleannoDefaults to true. Set false when the recipient name has not been confirmed against WeGo records. See Name verification.
sim_idstring or nullno
originstring or nullnoaggregated or assisted. Honoured only with X-API-Key, and defaults to aggregated. Ignored on a board token, which is always assisted. matching is refused here: it belongs to POST /operations/matching.
Responses
CodeConditionBody
202Accepted and queued{ "operation_id": "uuid", "status": "queued" }
401Bad API key{ "detail": "..." }
403The agent's access profile does not permit the operation{ "detail": "reason" }
409request_id already existsExisting operation object. Treat as success, not an error. Do not create a new id.
422Operator mismatch, unknown prefix, SIM frozen, no active SIM, no SIM with a measured balance, stale balance, or insufficient balance{ "detail": "reason" }
423Circuit breaker active{ "detail": "circuit_breaker" }
429Too many requests from this caller; retry after the Retry-After header

The 409 response is idempotent by design. n8n must handle it as a success and sync the existing operation, never mint a new request_id.

Response 202
json
Operator prefixes

The declared operator must match the destination number. A mismatch is rejected, and so is a prefix belonging to no operator.

OperatorPrefixes
MTN670 to 679, 650 to 654, 680, 682, 683
Orange690 to 699, 640 to 649, 655 to 659, 681, 684 to 689

681 is Orange while 680, 682 and 683 are MTN. Any other prefix, 66x included, is undetermined and refused rather than guessed.

Operation origins

Every operation records where it came from. Correlation reads it to narrow candidates before falling back across all origins, so two transfers of the same amount to the same number no longer orphan each other when one was dialled by hand.

OriginSet byMeaning
aggregatedX-API-Key with no origin, or origin: "aggregated"Came from intake through n8n. The default, so n8n keeps working unchanged.
assistedAny board session token, or X-API-Key with origin: "assisted"An agent created it from the board. The test console sends this explicitly, since it shares n8n's key.
matchingPOST /operations/matching onlyAn agent will dial it by hand. Refused on this route, so intake cannot fabricate a hand dialled transfer.

Origin comes from the credential, never from a board request body, for the same reason agent_id does: a caller controls the body and could otherwise misattribute its own work. A machine caller may declare one because the console and n8n share a single key and cannot be told apart by credential alone.

Balance and mini statement operations skip the check, since their client number is not a recipient.

Access profile

An agent may only run operations its profile permits: the selected SIM must be in allowed_sim_ids, the type in allowed_op_types, and the amount within max_amount. An agent with no profile, or with no SIMs assigned, can run nothing. Refusals return 403 naming the rule.

Admins bypass the profile. They do not bypass the balance rules.

Grant access with PATCH /admin/agents/{agent_id}/access. Until then a new agent runs nothing.

The two amount ceilings

max_amount exists in two places and means different things. Confusing them is easy, so:

WhereScopeEnforced
Agent ceilingaccess_profiles.max_amount, set by the access grantone operation, this agentyes
Fleet limitlimits.max_amount, set by PATCH /admin/limitsone operation, everyone on that operator and typeno

The agent ceiling is what produces a 403. The fleet limit is recorded and read back but nothing checks it when an operation is created, which is why GET /admin/limits reports enforced: false.

limits also carries max_daily_agent and max_daily_sim. Neither is enforced, and there is no daily cap of any kind today.

SIM selection

The caller sends operator. The server chooses the SIM and device, so no sim_id appears in the request.

A SIM is eligible when it is active, is assigned to an active device, and has a balance that has actually been measured. An unassigned SIM is a card in no handset, so it cannot dial regardless of its balance and is never selected. Among eligible SIMs, those that can cover the amount are preferred; ties go to the SIM with the fewest in-flight operations, then the one idle longest.

Balance checks are exempt from the measured balance requirement. A balance check is itself an operation, so requiring a measured balance to run one would leave a fleet with no way to become measured.

Balance rejections

balance_mirror is only written when a balance check SMS arrives, so a SIM that has never run one has an unknown balance rather than an empty one. Such a SIM is not selected, and a newly provisioned SIM therefore accepts no operations until a balance check has completed against it.

DetailMeaning
No active {operator} SIM availableNo active SIM on an active device for that operator
No active {operator} SIM is assigned to a deviceActive SIMs exist for that operator but all are in the unassigned pool. Assign one to a device
SIM {id} is not assigned to a deviceThe named SIM is in no handset. Assign it before using it
No {operator} SIM has a measured balanceSIMs exist but none has been measured. Run a balance check
SIM {id} has no measured balanceThe selected SIM has never been measured
SIM {id} balance was last measured at ...The reading is older than the staleness window
Insufficient balance on SIM {id}Names the requested amount, the available balance, and when it was measured
Operator mismatch: {msisdn} is {x}, but the operation declares {y}The destination prefix contradicts the declared operator
Cannot determine the operator for {msisdn}The prefix belongs to no operator

A balance reading older than 24 hours is refused as stale. The window is a setting, and whatever writes balance_mirror must write balance_updated_at with it, or a current figure carrying an old timestamp is refused for no visible reason.

GET/operations

List operations with optional filters.

A non-admin receives only its own operations. The scope comes from the token, not from agent_id, so an agent cannot read another agent's work by asking for it. An admin receives the whole fleet.

Board session token
Query parameters
ParameterInTypeDescription
statusquerystring or nullFilter by status: queued, running, awaiting_evidence, awaiting_manual_dial, confirmed, failed, no_evidence, rejected
sim_idquerystring or nullFilter by SIM id
agent_idquerystring or nullFilter by agent. Admins only. A non-admin is always scoped to itself, and passing another agent returns 403.
datequerystring or nullOne calendar day, YYYY-MM-DD. Resolved in the operating timezone (UTC+1), not UTC, so a day covers local midnight to midnight. A malformed value returns 422.
date_fromquerystring or nullInclusive start of a calendar range, YYYY-MM-DD. Optional, so date_to alone means "up to". Ignored when date is given.
date_toquerystring or nullInclusive end of a calendar range, YYYY-MM-DD. Optional, so date_from alone means "since". A range whose start is after its end returns 422.
searchquerystring or nullCase insensitive substring over recipient name, request_id, operation_id and destination number. The number is matched on digits alone, so 677 41 08 22 and 677000506 both match.
limitqueryintegerMax results. Default 50, max 250.

Filters combine. Each one narrows what the caller may already see, so none of them widens the scope the token established.

Response 200
json

  
    "operation_id" "uuid"
    "request_id" "wego-20260807-000412"
    "status" "confirmed"
    "origin" "assisted"
    "op_type" "deposit"
    "sim_id" "mtn-01"
    "contact_id" "contact-000001"
    "device_id" "device-000001"
    "device_label" "<device_label>"
    "agent_id" "agent-000001"
    "operator" "mtn"
    "montant" 25000
    "msisdn_dest" "670000528"
    "dest_name" "Awa N."
    "ussd_string" "<ussd_string>"
    "ussd_response" "<ussd_response>"
    "reject_reason" "<reject_reason>"
    "failure_reason" "<failure_reason>"
    "evidence" 
      "operator_ref" "<operator_ref>"
      "amount" 25000
      "fee" 1
      "balance_after" 1
      "raw_text" "<raw_text>"
      "received_at" "2026-09-23T10:15:00+01:00"
      "kind" "DEP"
      "is_ambiguous" 
    
    "retry_allowed" 
    "retry_requires_confirmation" 
    "created_at" "2026-08-07T14:22:00+01:00"
    "started_at" "2026-09-23T10:15:00+01:00"
    "finished_at" "2026-09-23T10:15:00+01:00"
  
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Only an admin can list another agent's operations
422Unknown status value
429Too many requests from this caller; retry after the Retry-After header
GET/operations/{operation_id}

Get a single operation by UUID.

Board session token

A non-admin can retrieve only an operation attributed to its token. An admin can retrieve any operation.

Path parameter: operation_id (UUID)

Response 200
json

  "operation_id" "uuid"
  "request_id" "wego-20260807-000412"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "mtn-01"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000528"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "Transaction effectuee. Ref: MP260807.1422.A12345"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "MP260807.1422.A12345"
    "amount" 25000
    "fee" 1
    "balance_after" 812300
    "raw_text" "<raw_text>"
    "received_at" "2026-08-07T14:23:41+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-08-07T14:22:00+01:00"
  "started_at" "2026-08-07T14:22:05+01:00"
  "finished_at" "2026-08-07T14:23:45+01:00"

evidence is null until an operator SMS is matched.

Retry flags are set by the API, not the board:

Statusretry_allowedretry_requires_confirmation
failed, rejectedtruefalse
no_evidencetruetrue
confirmed, queued, running, awaiting_evidencefalsen/a
Parameters
ParameterInTypeDescription
operation_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403You cannot access another agent's operation
404Operation not found
422The id is not a valid UUID
429Too many requests from this caller; retry after the Retry-After header
POST/operations/{operation_id}/cancel

Cancel a queued or running operation. Transitions the operation to rejected, releases the device slot, sets finished_at, and sets reject_reason to Cancelled by <full name>. so the board says who stopped it. Fires an operation.failed webhook with reason: cancelled_by_agent.

Terminal operations cannot be cancelled.

Board session token

A non-admin can cancel only an operation attributed to its token. An admin can cancel any operation.

Path parameter: operation_id (UUID)

Response 200: Updated operation detail with status: "rejected".

CodeCondition
200Cancelled
401Missing or invalid credentials
403You cannot access another agent's operation
404Operation not found
409Operation is already in a terminal state
422Invalid UUID format
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
operation_idpathstring
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
Operations are never deleted

There is no delete route. An operation records money moving, so removing one destroys the only evidence it happened while the audit trail keeps just the fact of the removal.

Deletion also broke behaviour that depends on the record surviving: retry_allowed is computed from whether a retry of an operation exists, so deleting the retry made the original retryable again and reopened the double payment guard, and GET /sims/{id}/history derives success rate and volume from operations, so deleting failures inflated it.

Use the alternatives instead: /cancel for a queued or running operation, /admin/operations/{id}/arbitrate for one that finished without confirmation, and the date, status and search filters to keep a list readable.

Manual matching

An agent sometimes dials on the handset directly, for cases the automated flow does not cover. Recording the transfer first gives the arriving SMS something to correlate against, so the customer still gets proof.

A matching operation is created with status: "awaiting_manual_dial" and is never queued. GET /device/next serves only queued, so no handset can claim one.

POST/operations/matching

Record a transfer the agent is about to dial by hand.

Board session token

Board only. The machine key names an arbitrary agent, so accepting it here would let intake fabricate work attributed to somebody who never dialled.

Request body
json

  "contact_id" "notion-page-id"
  "beneficiary_msisdn" "670000528"
  "montant" 25000
  "sim_id" "mtn-01"
FieldTypeRequiredDescription
contact_idstring or nullnoOpaque to Nexus. Where the proof is routed once the SMS confirms.
beneficiary_msisdnstringyesDestination MSISDN. The operator is derived from the prefix, so it is not asked for.
montantintegeryesAmount in FCFA (integer). Above 0
sim_idstring or nullnoName a SIM the agent holds, or omit it and the server selects one by operator.

The recipient name is not collected: the agent reads it off the handset while dialling.

Selection, access and balance rules are the assisted ones, unchanged. An agent who could not run this as a normal deposit cannot run it by hand, including the rule that an unmeasured SIM runs no operation.

CodeCondition
201Created. Returns the operation detail with status: "awaiting_manual_dial"
401Missing or invalid credentials
403The agent's access profile does not permit it, or the named SIM is not assigned to them
422Unknown prefix, operator mismatch, no eligible SIM, or an unmeasured or insufficient balance
429Too many requests from this caller; retry after the Retry-After header
Response 201
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
GET/operations/matching

List matching operations. A non-admin sees only its own; an admin sees all.

Board session token
Parameters
ParameterInTypeDescription
limitqueryintegerDefault 50
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "request_id" "wego-20260923-000001"
    "status" "confirmed"
    "origin" "assisted"
    "op_type" "deposit"
    "sim_id" "sim-000001"
    "contact_id" "contact-000001"
    "device_id" "device-000001"
    "device_label" "<device_label>"
    "agent_id" "agent-000001"
    "operator" "mtn"
    "montant" 25000
    "msisdn_dest" "670000001"
    "dest_name" "Awa N."
    "ussd_string" "<ussd_string>"
    "ussd_response" "<ussd_response>"
    "reject_reason" "<reject_reason>"
    "failure_reason" "<failure_reason>"
    "evidence" 
      "operator_ref" "<operator_ref>"
      "amount" 25000
      "fee" 1
      "balance_after" 1
      "raw_text" "<raw_text>"
      "received_at" "2026-09-23T10:15:00+01:00"
      "kind" "DEP"
      "is_ambiguous" 
    
    "retry_allowed" 
    "retry_requires_confirmation" 
    "created_at" "2026-09-23T10:15:00+01:00"
    "started_at" "2026-09-23T10:15:00+01:00"
    "finished_at" "2026-09-23T10:15:00+01:00"
  
PATCH/operations/matching/{operation_id}

Edit a matching operation while it is still unmatched. Accepts beneficiary_msisdn, montant and contact_id. Rewrites the pending row so correlation stays consistent with the edit.

Board session token
CodeCondition
200Updated
401Missing or invalid credentials
403Not the agent's own operation, and not an admin
404Matching operation not found
409Already matched or terminal, so no longer editable
422The id is not a valid UUID
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
operation_idpathstring
Request body
json

  "contact_id" "contact-000001"
  "beneficiary_msisdn" "670000001"
  "montant" 25000
Request fields
FieldTypeRequiredDescription
contact_idstring or nullno
beneficiary_msisdnstring or nullno
montantinteger or nullnoAbove 0
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
DELETE/operations/matching/{operation_id}

Cancel a matching operation while it is still unmatched. Sets it to rejected and drops the pending row.

Board session token
Parameters
ParameterInTypeDescription
operation_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403You cannot access another agent's operation
404Matching operation not found
409The matching operation has left awaiting_manual_dial and can no longer be changed
422The id is not a valid UUID
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
POST/operations/{operation_id}/attach

Attach an orphan evidence row to an operation by hand, for when correlation could not.

Board session token

Permitted to the agent named on the operation, plus supervisors and admins.

Request body: { "evidence_id": "uuid" }

Attaching sets the evidence operation_id, confirms the operation, settles any linked transaction, fires operation.confirmed, and drops the pending row.

CodeCondition
200Attached. Returns the confirmed operation detail
401Missing or invalid credentials
403Not the agent's operation, and not a supervisor or admin
404Operation or evidence not found
409The evidence already names an operation, or the operation is terminal
422The evidence arrived on a different SIM
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
operation_idpathstring
Request body
json

  "evidence_id" "3f2c0000-0000-4000-8000-000000000001"
Request fields
FieldTypeRequiredDescription
evidence_idUUIDyes
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
GET/operations/unproven

List transfers that dialled successfully and never got their proof: the arbitration queue.

Board session token

A non-admin sees transfers on the SIMs its profile assigns, plus its own whichever SIM they ran on. That second half matters because deleting a SIM nulls sim_id on the operations it leaves behind, and those would otherwise reach admins only.

Balance checks and mini statements are excluded. They move no money, so there is nothing to arbitrate.

Query parameters: limit (default 50, capped at 250)

Each row carries self_resolving, which says whether a late SMS can still confirm it without anyone acting. It is true while the operation is inside LATE_EVIDENCE_MINUTES.

Parameters
ParameterInTypeDescription
limitqueryintegerDefault 50
daysqueryinteger or null
fromquerystring or null
toquerystring or null
allquerybooleanDefault false
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "request_id" "wego-20260923-000001"
    "sim_id" "sim-000001"
    "device_id" "device-000001"
    "op_type" "deposit"
    "montant" 25000
    "msisdn_dest" "670000001"
    "dest_name" "Awa N."
    "contact_id" "contact-000001"
    "finished_at" "2026-09-23T10:15:00+01:00"
    "ussd_response" "<ussd_response>"
    "evidence_count" 1
    "self_resolving" 
  
POST/operations/{operation_id}/resolve

Settle a no_evidence transfer from the board.

Board session token

The agent who dialled it is the one who can ask the customer, so this is theirs to close rather than an admin only action. POST /admin/operations/{id}/arbitrate stays for the ops team and does the same thing.

Request body
json

  "outcome" "arrived"
  "note" "Customer confirmed on the phone."
FieldTypeRequiredDescription
outcomestringyesarrived confirms it, did_not_arrive fails it.
notestringyesWhat the decision rested on. No operator message backs it, so the note is the record.

Scoped like the queue: an agent settles a transfer on a SIM it is assigned, or one it dialled itself.

CodeCondition
200Settled. Returns the operation detail
401Missing or invalid credentials
403Not the agent's transfer and not on a SIM it holds
404Operation not found
409Not in no_evidence, so it already has its answer
422Empty note
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
operation_idpathstring
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "status" "confirmed"
  "origin" "assisted"
  "op_type" "deposit"
  "sim_id" "sim-000001"
  "contact_id" "contact-000001"
  "device_id" "device-000001"
  "device_label" "<device_label>"
  "agent_id" "agent-000001"
  "operator" "mtn"
  "montant" 25000
  "msisdn_dest" "670000001"
  "dest_name" "Awa N."
  "ussd_string" "<ussd_string>"
  "ussd_response" "<ussd_response>"
  "reject_reason" "<reject_reason>"
  "failure_reason" "<failure_reason>"
  "evidence" 
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "fee" 1
    "balance_after" 1
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "kind" "DEP"
    "is_ambiguous" 
  
  "retry_allowed" 
  "retry_requires_confirmation" 
  "created_at" "2026-09-23T10:15:00+01:00"
  "started_at" "2026-09-23T10:15:00+01:00"
  "finished_at" "2026-09-23T10:15:00+01:00"
GET/operations/settled-by-hand

Confirmed on somebody's word, and the operator has still said nothing.

Not work in the sense the other two queues are: the transfer is finished and shows as arrived. What is outstanding is the reconciliation. The message may yet turn up among the orphans beside this, and pairing it puts the operator's own record against a transfer a person vouched for.

Leaves the list the moment any evidence attaches, so nothing has to be dismissed by hand.

Scoped as the arbitration queue is, and for the same reason: a row names an amount and a customer.

Windowed on arbitrated_at, which is when somebody settled it. One control on the board drives all three queues, so a period means the same thing across them, and the other two filter on when the transfer stopped waiting rather than when it was created.

Board session token
Parameters
ParameterInTypeDescription
limitqueryintegerDefault 50
daysqueryinteger or null
fromquerystring or null
toquerystring or null
allquerybooleanDefault false
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "request_id" "wego-20260923-000001"
    "sim_id" "sim-000001"
    "device_id" "device-000001"
    "montant" 25000
    "msisdn_dest" "670000001"
    "dest_name" "Awa N."
    "contact_id" "contact-000001"
    "finished_at" "2026-09-23T10:15:00+01:00"
    "arbitrated_at" "2026-09-23T10:15:00+01:00"
    "arbitrated_by_name" "Awa N."
    "arbitration_note" "<arbitration_note>"
  
Evidence
GET/evidences

List operator messages that correlation could not attach to any transfer. The other half of the arbitration queue: a message with no transfer, where GET /operations/unproven lists transfers with no message.

Board session token
Query parameters
ParameterInTypeDescription
unmatchedquerybooleanMust be true. The route lists orphans only, and refuses otherwise rather than implying it can list everything.
limitqueryintegerCapped at 250.
daysqueryinteger or nullHow far back to look, in local calendar days.
allquerybooleanReach the whole backlog, past the days ceiling. An omitted days applies the default window rather than removing it, so a caller wanting everything has to say so.
fromquerystring or nullLocal calendar day to start from, YYYY-MM-DD. Wins over days.
toquerystring or nullLocal calendar day to end on, inclusive of that whole day.

A week is the work outstanding. The wider windows exist because nothing pruned this queue before, so a real decision can sit behind months of messages that no longer need one.

An orphan has no operation and therefore no agent, so scope comes from the SIM it arrived on: an agent sees orphans on the SIMs it is assigned, supervisors and admins see the fleet. Raw SMS text carries amounts and recipient numbers, so an unscoped read would show every agent the whole fleet's traffic.

Rows carry is_ambiguous, set when several transfers matched on amount and the message could not be assigned to one of them safely.

Status codes
CodeCondition
200Success
400Set unmatched=true to list orphan evidence
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
Response 200
json

  
    "id" "3f2c0000-0000-4000-8000-000000000001"
    "sim_id" "sim-000001"
    "source" "sms"
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "is_ambiguous" 
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "counterparty_msisdn" "670000001"
    "counterparty_name" "Awa N."
    "direction" "outgoing"
    "kind" "DEP"
  
POST/evidences/{evidence_id}/dismiss

Set aside a message no transfer will ever claim, so it leaves the queue without being attached to anything.

Board session token

Request body: { "reason": "why it will never pair" }

Idempotent: dismissing an already dismissed message is not an error, because two agents clearing the same backlog is ordinary. Scoped like the list, so an agent resolves only what it can see.

CodeCondition
204Dismissed
401Missing or invalid credentials
403The message arrived on a SIM the agent is not assigned
404Evidence not found
409This message is already attached to an operation
Parameters
ParameterInTypeDescription
evidence_idpathUUID
Request body
json

  "reason" "balance_reading"
Request fields
FieldTypeRequiredDescription
reasonstringyesOne of balance_reading, operator_refusal, already_settled, not_ours, other
GET/evidences/movements

Return float and UV transfers, withdrawals, airtime sales and commission sweeps, newest first.

Each moved money on a SIM, and none has an operation of its kind to confirm, so they are kept out of the loose queue and listed here as a record. A dismissed movement still moved money, so dismissal does not hide it. Scoped by SIM exactly as the unmatched list is.

Board session token
Parameters
ParameterInTypeDescription
limitqueryinteger1 to 250. Default 50
daysqueryinteger or nullHow far back to look, in local calendar days.
allquerybooleanReach every movement, past the days ceiling.
fromquerystring or nullLocal calendar day to start from, YYYY-MM-DD. Wins over days.
toquerystring or nullLocal calendar day to end on, inclusive of that whole day.
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
Response 200
json

  
    "id" "3f2c0000-0000-4000-8000-000000000001"
    "sim_id" "sim-000001"
    "source" "sms"
    "operator_ref" "<operator_ref>"
    "amount" 25000
    "is_ambiguous" 
    "raw_text" "<raw_text>"
    "received_at" "2026-09-23T10:15:00+01:00"
    "counterparty_msisdn" "670000001"
    "counterparty_name" "Awa N."
    "direction" "outgoing"
    "kind" "DEP"
  
SMS kinds

Every stored message carries a kind, decided once from its text in app/sms/kind.py. The board shows the code as written.

KindMessageTreatment
DEPA deposit to a customerCan confirm a transfer
BALA balance replyCan confirm a balance check only
UV_OUT, UV_INA UV transfer sent or receivedMovement
FLOAT_OUT, FLOAT_INA float transfer sent or receivedMovement
UV_WITH, FLOAT_WITHA withdrawalMovement
AIRTIMEAn airtime saleMovement
COM_SWEEPCommission moved to the main accountMovement
REVAn earlier transfer returned to our SIMMovement
MTN_REC, ORANGE_RECA mini statementStored, never matched
ADMIN, COM, MKT, TPLAn account notice, commission notice, marketing, or an unfilled templateStored, never matched
FAILAn operator refusalStored, never matched

A movement moved money that no operation of ours asked for, so it stays out of the unmatched queue and is listed by GET /evidences/movements. A message no rule recognises has kind: null and is treated as a possible deposit, because refusing a real confirmation is the costlier mistake.

Device

Routes called by the Nexus Android app. Auth is a device-scoped bearer token.

GET/device/next

Poll for the next queued operation assigned to the calling device. The app calls this every 3-5 seconds.

Authorization: Bearer <device_token>

Each device only receives operations assigned to its own device_id. Operations for other devices are never returned, regardless of queue state.

Response 200 (operation available)
json

  "operation_id" "uuid"
  "sim_id" "mtn-01"
  "subscription_slot" 0
  "ussd_string" "*126*1*670000528*25000#"
  "ussd_steps" 
    "*126#"
  
  "expected_sender" "MTN"
  "timeout_seconds" 90
  "montant" 25000
  "op_type" "deposit"
  "wego_verified" 
  "dest_name" "Awa N."
  "msisdn_dest" "670000001"
  "operator" "mtn"
FieldTypeDescription
ussd_stepsarray of stringsOrdered menu entries. The device sends element 0 through the dialer, then replies with each remaining element as the operator prompts. The final element is the SIM PIN for deposit and balance operations.
ussd_stringstringLegacy single-string form, retained for the operation record. The device executes ussd_steps.
op_typestringOperation type. The device uses this to decide whether a name check applies.
wego_verifiedbooleanWhen false on a deposit, the device pauses at the confirmation screen and compares the operator name against dest_name.
dest_namestring or nullExpected recipient name, compared against the name the operator returns.

A single-element ussd_steps array is executed through sendUssdRequest and opens no dialog. Orange balance is the only operation of this shape.

Both ussd_steps and ussd_string contain the decrypted SIM PIN. Neither may appear in any log, on the server or the device.

The operation status moves to running at the moment this response is sent. A partial unique index (device_id WHERE status='running') ensures one running operation per device.

Response 204: No queued operations for this device, or the device is inside device_cooldown_seconds after a finished operation.

Response 409: Device already has a running operation.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
429Too many requests from this caller; retry after the Retry-After header
500The USSD steps could not be built for this operation
POST/device/result

Report the outcome of a USSD execution.

Authorization: Bearer <device_token>
Request body
json

  "operation_id" "uuid"
  "outcome" "ussd_ok"
  "ussd_response" "Transaction en cours de traitement..."
  "executed_at" "2026-08-07T14:22:12+01:00"
  "failure_detail" 
FieldTypeRequiredDescription
operation_idUUIDyesOperation being reported.
outcomestringyesSee the table below.
ussd_responsestring or nullnoVerbatim operator text from the final dialog. Stored as ussd_response.
executed_atdatetimeyesWhen the device executed the operation.
failure_detailstring or nullnoExplanation of the failure, stored as failure_reason. Keeps the reason separate from the operator text. When omitted, failure_reason falls back to ussd_response for name_mismatch and to the outcome value otherwise.

outcome values:

ValueEffectTerminal
ussd_okMove to awaiting_evidence, release the device, and wait for SMS within the evidence windowNo
ussd_unconfirmedThe PIN was sent and the operator showed no closing screen. Move to awaiting_evidence like ussd_ok, so the SMS decidesNo
ussd_failedMove to failedYes
ussd_timeoutMove to no_evidenceYes
no_serviceMove to no_evidenceYes
sim_absentMove to failed, trigger alertYes
name_mismatchMove to failed. The PIN was never sent and no funds moved.Yes

Response: Always 200, including if the operation is already in a terminal state. This call is idempotent, so the app may retry after a network drop.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Operation not found
409Operation result has already been recorded
422The id is not a valid UUID
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "status" "confirmed"
  "evidence_expires_at" "2026-09-23T10:15:00+01:00"
POST/device/sms

Submit operator SMS messages for correlation. The app forwards all messages from known operator senders; the server handles matching.

Authorization: Bearer <device_token>
Request body
json

  "sim_id" "mtn-01"
  "subscription_slot" 1
  "correlate" 
  "messages" 
    
      "sender" "MTNMobileMoney"
      "raw" "Vous avez transfere 25000 FCFA a 670000528. Frais: 250 FCFA. Nouveau solde: 812300 FCFA. Ref: MP260807.1422.A12345"
      "received_at" "2026-08-07T14:23:41+01:00"
      "device_received_at" "2026-08-07T14:23:44+01:00"
      "client_message_id" "spool-8f2c1a40"
    
  
Message fieldRequiredMeaning
senderyesThe operator address the message came from.
rawyesThe message text, unmodified.
received_atyesThe operator's timestamp, as the handset reports it.
device_received_atnoWhen the handset itself saw the message. An SMSC can encode an offset that is wrong by hours, and a message the operator queued during an outage reports when it was first sent, so this decides whether a message predates its operation when it is supplied.
client_message_idnoIdentity the device assigns before its first attempt, at most 64 characters. A device that keeps its own queue sends the same value on every retry.

Server-side correlation steps:

  1. Deduplicate on sms_hash, which carries client_message_id when the device sends one. Resubmission is safe, and two genuine messages with identical text stay apart. Without an id, identical text on one SIM is treated as the same message, so two balance replies quoting the same figure collapse into one.
  2. Parse with the active operator parser: extract montant, frais, balance_after, operator_ref.
  3. Correlate to an active operation (queued, running, awaiting_evidence, or awaiting_manual_dial) on the same SIM by exact amount. A no_evidence operation is also eligible while its finished_at is within late_evidence_minutes; active candidates are tried first.
  4. Coherent match: move to confirmed, update balance_mirror from balance_after.
  5. Divergent amount or MSISDN: no confirmation, trigger alert.
  6. A reference already recorded against a matched message on the same SIM is a restatement, not a second movement. It is attached to the same operation and answers restated.
  7. No match: store as orphan with operation_id = NULL. Never deleted.
Response 200
FieldTypeRequiredDescription
sim_idstringyes
subscription_slotinteger or nullno
correlatebooleannoDefault true
messagesarray of objectyes
json

  "matched" 
  "restated" 

A restated message is the operator describing one movement a second time, not the transfer running twice. MTN sends two messages for a deposit, worded differently and sometimes omitting the commission, so the deduplication hash cannot see the repeat. Both messages are kept because the wordings do not carry the same fields, and both point at the same operation.

The rule keys on operator_ref. A genuine second dial carries a reference of its own, so it stays an orphan and reaches arbitration, which is what surfaces money that moved twice.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
403SIM is not assigned to this device
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "matched" 
  "kind" "DEP"
  "outcome" "matched"
  "operation" 
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "request_id" "wego-20260923-000001"
    "status" "confirmed"
    "origin" "assisted"
    "op_type" "deposit"
    "sim_id" "sim-000001"
    "contact_id" "contact-000001"
    "device_id" "device-000001"
    "device_label" "<device_label>"
    "agent_id" "agent-000001"
    "operator" "mtn"
    "montant" 25000
    "msisdn_dest" "670000001"
    "dest_name" "Awa N."
    "ussd_string" "<ussd_string>"
    "ussd_response" "<ussd_response>"
    "reject_reason" "<reject_reason>"
    "failure_reason" "<failure_reason>"
    "evidence" 
      "operator_ref" "<operator_ref>"
      "amount" 25000
      "fee" 1
      "balance_after" 1
      "raw_text" "<raw_text>"
      "received_at" "2026-09-23T10:15:00+01:00"
      "kind" "DEP"
      "is_ambiguous" 
    
    "retry_allowed" 
    "retry_requires_confirmation" 
    "created_at" "2026-09-23T10:15:00+01:00"
    "started_at" "2026-09-23T10:15:00+01:00"
    "finished_at" "2026-09-23T10:15:00+01:00"
  
  "restated" 
  "balance" 1
  "previous_balance" 1
POST/device/heartbeat

Signal device liveness. Must be called every 60 seconds.

Authorization: Bearer <device_token>
Request body
json

  "app_version" "1.0.3"
  "battery" 87
  "network" "4G"
  "sims" 
    
      "sim_id" "mtn-01"
      "present" 
      "signal" 3
    
  
  "android_version" "<android_version>"
  "android_sdk" 1
  "manufacturer" "<manufacturer>"
  "model" "<model>"
  "battery_exempt" 
  "report" 

A device silent for more than 5 minutes triggers an alert. Heartbeat freshness does not gate operation queuing; the device claims queued operations when it comes back online.

Response 204: no body.

report is the health report Nexus Agent 1.1.0 and later sends with every heartbeat. Every group and field is optional, and an older app sends none, which GET /devices/{device_id}/health reports as the no_report issue.

GroupFields
appversion, ui (open, background or closed), uptime_seconds
androidversion, sdk, manufacturer, model, oem_family
batterylevel (0 to 100), charging
networktype, connected, internet_reachable
permissionscall_phone, read_phone_state, read_phone_numbers, receive_sms, read_sms, notifications
accessibilityenabled, running
backgroundbattery_exempt, service_running, foreground, service_type, wake_lock_held, time_limit_stops, last_time_limit_stop_at
pollingwanted, last_tick_at, last_poll_at, consecutive_failures, backoff_until, stuck_ticks, dialling, last_error
smsoutbox_pending, outbox_oldest_at, spool_pending, spool_oldest_at, spool_insert_failures, spool_last_failure
simsUp to 4 of slot, operator, sim_id, present
recent_errorsUp to 5 of at, message
Request fields
FieldTypeRequiredDescription
app_versionstringyes
batteryintegeryes0 to 100
networkstringyes
simsarray of objectyes
android_versionstring or nullnoUp to 40 characters
android_sdkinteger or nullno
manufacturerstring or nullnoUp to 80 characters
modelstring or nullnoUp to 80 characters
battery_exemptboolean or nullno
reportobject or nullno
Status codes
CodeCondition
204Done, no body
401Missing or invalid credentials
429Too many requests from this caller; retry after the Retry-After header
GET/device/operations

Operations the calling device claimed, most recently changed first.

Device scoped, like every route here: a phone sees its own work and nothing else. Only claimed operations are listed, since a queued one has not reached the phone.

Device token, Authorization: Bearer <device_token>
Parameters
ParameterInTypeDescription
sincequerydatetime or nullOnly operations that changed at or after this time.
limitqueryinteger1 to 250. Default 100
Status codes
CodeCondition
200Success
401Missing or invalid credentials
422Invalid since or limit
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "status" "confirmed"
    "op_type" "deposit"
    "operator" "mtn"
    "montant" 25000
    "msisdn_dest" "670000001"
    "dest_name" "Awa N."
    "sim_id" "sim-000001"
    "sim_slot" 1
    "reject_reason" "<reject_reason>"
    "failure_reason" "<failure_reason>"
    "started_at" "2026-09-23T10:15:00+01:00"
    "finished_at" "2026-09-23T10:15:00+01:00"
    "evidence_expires_at" "2026-09-23T10:15:00+01:00"
    "sms_kind" "<sms_kind>"
    "updated_at" "2026-09-23T10:15:00+01:00"
  
GET/device/sims

The SIMs assigned to the calling device, with their measured balance.

A device sees only its own cards, since a balance is fleet information the handset has no reason to hold for another phone.

Device token, Authorization: Bearer <device_token>
Status codes
CodeCondition
200Success
401Missing or invalid credentials
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "sim_id" "sim-000001"
    "slot" 1
    "operator" "mtn"
    "msisdn" "670000001"
    "status" "confirmed"
    "balance" 1
    "balance_updated_at" "2026-09-23T10:15:00+01:00"
  
GET/device/me

The calling device's id, fleet label and status, for the name the phone shows.

Device token, Authorization: Bearer <device_token>
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Device not found
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "device_id" "device-000001"
  "label" "<label>"
  "status" "confirmed"
Name verification

Deposits carry wego_verified, which controls whether the device validates the recipient name before authorising the transfer.

wego_verified: true means the caller has already confirmed the recipient against WeGo records. The device executes every step without pausing.

wego_verified: false means only the name typed by the user is known. The device dials every step up to the confirmation screen, holds before sending the PIN, reads the name the operator returns, and compares it against dest_name.

Both operators show the recipient name on the same screen that requests the PIN:

OperatorConfirmation text
MTNConfirmez le depot de 500 FCFA à ADELINE BIKOA (237683000520).Entrez votre code PIN:
OrangeDepot de 500 FCFA sur le compte mobile 690000501 SAMUEL PIERRE NKOLO. Entrez votre code secret pour confirmer le depot ou 2 pour annuler

Comparison rules:

  • Both names are uppercased and stripped of diacritics before matching.
  • The words of the shorter name must all appear in the longer name, so an omitted middle name still matches.
  • A repeated surname still matches, because comparison is by word set.
  • When the name cannot be read from the screen, the result is treated as a mismatch.

On a mismatch the device reports name_mismatch and closes the dialog without sending the PIN, so no funds move. The operation moves to failed, ussd_response holds the operator text, and failure_reason holds the comparison that failed.

Balance and mini-statement operations are unaffected. They have no recipient, so they carry wego_verified: false and dial without a name check.

Admin

Routes for operational management. All require a board session token belonging to an agent with the admin role:

text
Authorization: Bearer <session_token>

A non-admin agent receives 403. The machine API key is not accepted on these routes.

POST/admin/sims/{sim_id}/freeze

Prevent new operations on a SIM. In-flight operations continue.

Response 200: { "status": "frozen" }

Board session token, admin role
Parameters
ParameterInTypeDescription
sim_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404SIM not found
Response 200
json
POST/admin/sims/{sim_id}/unfreeze

Re-enable a frozen SIM.

Response 200: { "status": "active" }

Board session token, admin role
Parameters
ParameterInTypeDescription
sim_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404SIM not found
Response 200
json
GET/admin/sims

Snapshot of all SIMs with status, balance mirror, and last heartbeat.

Response 200
json

  
    "id" "id-000001"
    "device_id" "a1b2c3d4e5f6a7b8"
    "subscription_slot" 1
    "operator" "mtn"
    "msisdn" "670000001"
    "balance_mirror" 1
    "balance_updated_at" "2026-09-23T10:15:00+01:00"
    "status" "active"
    "low_balance_threshold" 50000
    "created_at" "2026-08-07T10:06:00+01:00"
  
Board session token, admin role
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
POST/admin/circuit-breaker

Freeze the entire fleet immediately. All POST /operations return 423 until lifted.

Request body
json
 "active"  

Pass "active": false to lift the breaker.

Response 200: { "status": "active" } or { "status": "inactive" }

Board session token, admin role
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
Response 200
json
POST/admin/operations/{operation_id}/arbitrate

Manually resolve a no_evidence operation after the late evidence window. A matching SMS can confirm it automatically while late_evidence_minutes is still open.

Request body
json

  "decision" "<decision>"
  "note" "<note>"

resolution must be "confirmed" or "failed".

Response 200: Updated operation object.

Board session token, admin role
Parameters
ParameterInTypeDescription
operation_idpathstring
Request fields
FieldTypeRequiredDescription
decisionstringyesArbitration decision: confirmed or failed
notestringyesReason for decision. Up to 500 characters
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Operation not found
422The id is not a valid UUID
Response 200
json
PATCH/admin/limits

Record a fleet limit for an operator and operation type. Versioned: the current row is deactivated and a new one inserted with effective_from, so limits are changeable policy with history rather than a one time setting.

Not enforced. Nothing reads these caps when an operation is created, so a row records intent. The ceiling that is enforced is the agent's own, on their access profile.

Request body
json

  "operator" "mtn"
  "op_type" "deposit"
  "max_amount" 500000
  "max_daily_agent" 2000000
  "max_daily_sim" 5000000

operator null sets the global limit, which applies where no operator specific row exists. All three amounts are required and must be positive.

op_type accepts only the types that move money: deposit, withdrawal, float_deposit. A limit on balance, mini_statement or diagnostic is refused with 422, because those carry no real amount: montant is required so they send a dummy 1, and a cap on them would constrain a fabricated number while reading as real policy.

Response 200: the recorded limit.

Board session token, admin role
Request fields
FieldTypeRequiredDescription
operatorstring or nullnoOperator code (mtn, orange) or null for global limit
op_typestringnoOperation type. Only types that move money can carry a limit: balance, mini_statement and diagnostic carry no real amount.. One of deposit, withdrawal, float_deposit. Default "deposit"
max_amountintegeryesMaximum single transaction amount in FCFA. Above 0
max_daily_agentintegeryesMaximum daily total per agent in FCFA. Above 0
max_daily_simintegeryesMaximum daily total per SIM in FCFA. Above 0
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
422Unknown operator code
Response 200
json
GET/admin/limits

Return every active limit.

Response 200
json

  "enforced" 
  "limits" 
    
      "operator" "mtn"
      "op_type" "deposit"
      "max_amount" 500000
      "max_daily_agent" 2000000
      "max_daily_sim" 5000000
      "effective_from" "2026-08-26T14:22:00+01:00"
    
  

enforced is false deliberately, so nobody implements against a cap that does not bind. A row with operator null is the global limit.

Board session token, admin role
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
PATCH/admin/agents/{agent_id}/access

Grant an agent access to SIMs, operation types and a per operation ceiling.

An agent has no access by default: profile_id is null on a new agent, so every operation it creates returns 403 until an admin grants it. Admins are not subject to a grant.

Request body
json

  "allowed_sim_ids" 
    "sim-abc123def456"
  
  "allowed_op_types" 
    "deposit"
  
  "max_amount" 500000
FieldTypeRequiredDescription
allowed_sim_idsarray of stringyesSIM ids this agent may use. Must name at least one.
allowed_op_typesarray of stringnoOperation types this agent may run. Defaults to deposit and balance, since a SIM cannot run anything until a balance check has measured it.. Default ["deposit", "balance"]
max_amountintegernoCeiling for a single operation in FCFA, defaulting to 500000. Not a daily total.. Above 0. Default 500000

allowed_op_types defaults to deposit and balance because a SIM cannot run anything until a balance check has measured it. An agent without balance would hold SIMs it can never make usable.

max_amount defaults to 500000, so a grant can name only the SIMs. Zero is still refused, since a ceiling of zero would grant SIMs the agent could never use.

allowed_sim_ids has no default and must name at least one SIM. An empty list would look like a grant while leaving the agent unable to work.

This is the only amount rule the API applies. Daily totals and fleet wide caps are handled outside it.

Unknown SIM ids and unknown operation types are refused with 422 naming what was rejected, so a typo does not silently grant nothing.

dual_approval_above is not settable and is no longer read. It meant a second person signs off above an amount, and Operation.approved_by exists for it, but there is no approval flow, so enforcing it refused those operations outright.

Response 200: the agent's grant after the change.

Board session token, admin role
Parameters
ParameterInTypeDescription
agent_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Agent not found
422One or more SIM ids do not exist
Response 200
json

  "agent_id" "agent-000001"
  "login" "<login>"
  "role" "agent"
  "is_active" 
  "allowed_sim_ids" 
    "<allowed_sim_ids>"
  
  "allowed_op_types" 
    "<allowed_op_types>"
  
  "max_amount" 25000
GET/admin/agents/{agent_id}/access

Return what an agent is currently allowed to do. An agent with no grant reads as empty lists and a null max_amount rather than an error, since that is its real state.

Response 200
json

  "agent_id" "uuid"
  "login" "agent-01"
  "role" "agent"
  "is_active" 
  "allowed_sim_ids" 
    "sim-abc123def456"
  
  "allowed_op_types" 
    "deposit"
  
  "max_amount" 500000
Board session token, admin role
Parameters
ParameterInTypeDescription
agent_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Agent not found
POST/admin/cleanup

Delete operational data for a production fresh start. Irreversible.

Removes operations, evidences, notifications, webhook deliveries, daily reconciliations, devices, SIMs, sessions and non-admin agents. Preserves access profiles, limits, the audit log, and every agent holding the admin role, so the login that authorised the call survives.

Request body
json

  "confirm" "RESET NEXUS PRODUCTION DATA"

The phrase is part of the request schema. Any other value returns 422 before a single row is touched.

Response 200
json

  "cleaned" 
  "deleted" 
  "preserved" 
  "queue_purged" 
  "notes" 
    "Every SIM was removed. Re-provision devices and SIMs before creating operations."
  

The Redis operation queue is purged, since it holds ids for operations that no longer exist.

One audit_log row is written inside the same transaction as the deletions, carrying the per table counts. After this runs it is the only record of what was removed.

After a cleanup: re-provision devices and SIMs, then run a balance check on each SIM. Until a SIM has a measured balance it accepts no operations.

Board session token, admin role
Request fields
FieldTypeRequiredDescription
confirmstringyesMust be exactly 'RESET NEXUS PRODUCTION DATA'.
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
DELETE/admin/circuit-breaker

Lift the circuit breaker: restore all frozen devices to active.

Sets all devices with status=FROZEN to status=ACTIVE. Creates audit log entry.

Board session token, admin role
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
Response 200
json
GET/admin/health

What /health reports, plus the fleet: devices online, SIMs unmeasured or stale, and transfers past the evidence window that the sweep has not reached.

Admin only, because device and SIM counts describe the fleet.

Board session token, admin role
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
Response 200
json
Agents
POST/agents/login

Authenticate an agent and receive a session token.

None
Request body
json

  "login" "test-agent"
  "password" "test-password-123"
Response 200
json

  "token" "session-token-string"
  "agent_id" "uuid"
  "role" "agent"
  "expires_at" "2026-08-07T19:22:00+01:00"
  "must_change_password" 

Token is valid for 5 hours from issue time. It is not extended by subsequent requests.

Request fields
FieldTypeRequiredDescription
loginstringyesAgent login username. Up to 100 characters
passwordstringyesAgent password. Up to 100 characters
Status codes
CodeCondition
200Success
401Missing or invalid credentials
429Too many requests from this caller; retry after the Retry-After header
POST/agents/logout

Revoke the current session.

Board session token

Response 204: No body.

Status codes
CodeCondition
204Done, no body
401Missing or invalid credentials
429Too many requests from this caller; retry after the Retry-After header
GET/agents/me

Return the authenticated agent's profile.

Board session token
Response 200
json

  "agent_id" "uuid"
  "login" "test-agent"
  "full_name" "Test Agent"
  "role" "agent"
  "is_active" 
  "created_at" "2026-08-01T10:00:00+01:00"
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
GET/agents/me/access

Read the calling agent's own limits: the SIMs it may send from, the operation types it may run, and its per operation ceiling.

Board session token

The equivalent under /v1/admin requires the admin role, so an agent had no way to read its own limits and the first sign of one was a refused operation. The board uses this to show the ceiling and to offer only the operation types the agent holds.

An admin bypasses the profile entirely, so read role rather than the lists.

Response 200
json

  "agent_id" "uuid"
  "login" "amina.b"
  "role" "agent"
  "is_active" 
  "allowed_sim_ids" 
    "uuid"
  
  "allowed_op_types" 
    "deposit"
  
  "max_amount" 500000
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
GET/agents/me/sims

Return the calling agent's assigned SIM summaries and their measured balances.

Board session token

The result is limited to the SIM ids in the caller's access profile. It contains no PIN, device token, or fleet-wide data. An agent with no profile or no assigned SIMs receives an empty array.

Response 200
json

  
    "sim_id" "sim-abc123def456"
    "operator" "mtn"
    "msisdn" "670000001"
    "status" "active"
    "balance_mirror" 812300
    "balance_updated_at" "2026-08-27T10:00:00Z"
    "device_id" "device-000001"
    "device_label" "<device_label>"
    "subscription_slot" 1
  
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
429Too many requests from this caller; retry after the Retry-After header
PATCH/agents/me/password

Change the current agent's password. Revokes all active sessions on success.

Board session token
Request body
json

  "current_password" "old-password"
  "new_password" "new-password"

Response 204: No body. The agent must log in again.

Clears must_change_password if an admin had issued a temporary password.

Request fields
FieldTypeRequiredDescription
current_passwordstringyes
new_passwordstringyesUp to 100 characters
Status codes
CodeCondition
204Done, no body
401Missing or invalid credentials
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
POST/agents/bootstrap

Create the first admin on a database that has none. Creating an agent requires the admin role, so this is the only way to get the first one.

X-Bootstrap-Token, matching BOOTSTRAP_TOKEN

Two conditions, both required: the token matches, and no admin exists. The second is what limits a leaked token, since the route stops working once it has been used. role in the body is ignored; the account is always created as admin.

StatusMeaning
201Admin created
403Token absent, wrong, or BOOTSTRAP_TOKEN unset
409An admin already exists, or the login is taken

Leave BOOTSTRAP_TOKEN unset once the first admin exists. Further admins are created with POST /agents/admin.

Request body
json

  "login" "<login>"
  "full_name" "Awa N."
  "password" "<password>"
  "role" "agent"
Request fields
FieldTypeRequiredDescription
loginstringyesUp to 100 characters
full_namestringyesUp to 200 characters
passwordstringyesUp to 100 characters
rolestringnoDefault "agent"
Status codes
CodeCondition
201Created
403Bootstrap is not available
409An admin already exists. Create further agents with POST /v1/agents/admin
429Too many requests from this caller; retry after the Retry-After header
Response 201
json

  "agent_id" "agent-000001"
  "login" "<login>"
  "full_name" "Awa N."
  "role" "agent"
  "is_active" 
  "created_at" "2026-09-23T10:15:00+01:00"
POST/agents/admin

Create a new agent account. Admin only. Set "role": "admin" to create another admin.

Board JWT, admin role
Request body
json

  "login" "new-agent"
  "full_name" "New Agent"
  "password" "initial-password"
  "role" "agent"

role values: "agent", "supervisor", "admin"

Response 201: Agent profile object.

Request fields
FieldTypeRequiredDescription
loginstringyesUp to 100 characters
full_namestringyesUp to 200 characters
passwordstringyesUp to 100 characters
rolestringnoDefault "agent"
Status codes
CodeCondition
201Created
401Missing or invalid credentials
403Admin role required
409An agent with this login already exists
429Too many requests from this caller; retry after the Retry-After header
Response 201
json

  "agent_id" "agent-000001"
  "login" "<login>"
  "full_name" "Awa N."
  "role" "agent"
  "is_active" 
  "created_at" "2026-09-23T10:15:00+01:00"
POST/agents/admin/{agent_id}/password-reset

Replace an agent's password with a generated temporary one. Admin only.

Board JWT, admin role

An agent who forgets their password has no other way back: PATCH /agents/me/password needs the current password, and the admin patch route sets full_name, role and is_active, never a password.

The password is generated rather than chosen, so one cannot be reused across the team, and it is returned once. Nothing stores the plaintext. must_change_password is set, so the password stops working as soon as the agent signs in and picks their own, and every session is revoked.

Response 200
json

  "agent_id" "uuid"
  "login" "amina.b"
  "temporary_password" "pebble-flint-jasper-1337"
StatusMeaning
200Temporary password issued
403Caller is not an admin
404No such agent
Parameters
ParameterInTypeDescription
agent_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
GET/agents/admin

List all agent accounts.

Board JWT, admin role

Response 200: Array of agent profile objects.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "agent_id" "agent-000001"
    "login" "<login>"
    "full_name" "Awa N."
    "role" "agent"
    "is_active" 
    "created_at" "2026-09-23T10:15:00+01:00"
  
PATCH/agents/admin/{agent_id}

Update an agent's name, role, or active status.

Board JWT, admin role
Request body (all fields optional)
json

  "full_name" "Updated Name"
  "is_active" 
  "role" "supervisor"

Response 200: Updated agent profile object.

Parameters
ParameterInTypeDescription
agent_idpathstring
Request fields
FieldTypeRequiredDescription
full_namestring or nullnoUp to 200 characters
is_activeboolean or nullno
rolestring or nullno
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "agent_id" "agent-000001"
  "login" "<login>"
  "full_name" "Awa N."
  "role" "agent"
  "is_active" 
  "created_at" "2026-09-23T10:15:00+01:00"
DELETE/agents/admin/{agent_id}

Deactivate an agent and revoke all active sessions. The account is not deleted.

Board JWT, admin role

Response 204: No body. Idempotent.

Parameters
ParameterInTypeDescription
agent_idpathstring
Status codes
CodeCondition
204Done, no body
401Missing or invalid credentials
403Admin role required
404Agent not found
429Too many requests from this caller; retry after the Retry-After header
Devices

Routes for provisioning Android devices. Every route requires a Board session token belonging to an admin.

POST/devices

Register a new Android device and receive a device token.

Board session token, admin role
Request body
json

  "label" "Phone-MTN-01"
Response 201
json

  "device_id" "a1b2c3d4e5f6a7b8"
  "device_token" "64-character-hex-token"
  "label" "Phone-MTN-01"
  "created_at" "2026-08-07T10:00:00+01:00"

device_id is a hex string. device_token is returned once and never stored. Pass it to the Android app as its Bearer credential. It cannot be retrieved again.

Request fields
FieldTypeRequiredDescription
labelstringyesUp to 200 characters
Status codes
CodeCondition
201Created
401Missing or invalid credentials
403Admin role required
429Too many requests from this caller; retry after the Retry-After header
GET/devices

List all devices with their SIMs and last heartbeat.

Board session token, admin role
Response 200
json

  
    "device_id" "a1b2c3d4e5f6a7b8"
    "label" "Phone-MTN-01"
    "status" "active"
    "last_seen_at" "2026-08-07T14:20:00+01:00"
    "sims" 
      
        "id" "sim-abc123def456"
        "operator" "mtn"
        "msisdn" "670000001"
        "status" "active"
        "subscription_slot" 0
      
    
    "app_version" "<app_version>"
    "android_version" "<android_version>"
    "manufacturer" "<manufacturer>"
    "model" "<model>"
    "battery" 1
    "network" "<network>"
    "health_reported_at" "2026-09-23T10:15:00+01:00"
    "issues" 
      "count" 1
      "worst" "critical"
    
  
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
429Too many requests from this caller; retry after the Retry-After header
GET/devices/{device_id}

Return detail for a single device, including its linked SIMs.

Board session token, admin role

Response 200: Device object with sims array. Each SIM includes subscription_slot, where 0 is SIM 1 and 1 is SIM 2.

CodeCondition
200Device found
401Missing or invalid credentials
403Admin role required
404Device not found
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
device_idpathstring
Response 200
json

  "device_id" "device-000001"
  "label" "<label>"
  "status" "confirmed"
  "last_seen_at" "2026-09-23T10:15:00+01:00"
  "created_at" "2026-09-23T10:15:00+01:00"
  "sims" 
    
      "id" "id-000001"
      "operator" "mtn"
      "msisdn" "670000001"
      "status" "confirmed"
      "subscription_slot" 1
    
  
PATCH/devices/{device_id}

Update a device's label.

Board session token, admin role
Request body
json

  "label" "New Label"

Response 200: Updated device object.

Parameters
ParameterInTypeDescription
device_idpathstring
Request fields
FieldTypeRequiredDescription
labelstring or nullnoUp to 200 characters
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Device not found
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "device_id" "device-000001"
  "label" "<label>"
  "status" "confirmed"
  "last_seen_at" "2026-09-23T10:15:00+01:00"
  "created_at" "2026-09-23T10:15:00+01:00"
  "sims" 
    
      "id" "id-000001"
      "operator" "mtn"
      "msisdn" "670000001"
      "status" "confirmed"
      "subscription_slot" 1
    
  
DELETE/devices/{device_id}

Delete a device and return its SIMs to the unassigned pool.

The SIMs survive. A SIM is a physical card that outlives the handset holding it, so device_id and subscription_slot are cleared on each of them and nothing else changes: MSISDN, measured balance, evidence and reconciliation rows are all preserved, and the cards become usable again once assigned to another device.

Operations keep sim_id, since the SIM still exists and its history stays attributable. Only device_id is nulled on them.

The audit row records the unassigned SIM ids in its payload, because nothing else links those cards to the device they came from once it is gone.

Board session token, admin role

Response 204: No body.

Parameters
ParameterInTypeDescription
device_idpathstring
Status codes
CodeCondition
204Done, no body
401Missing or invalid credentials
403Admin role required
404Device not found
429Too many requests from this caller; retry after the Retry-After header
GET/devices/{device_id}/health

The latest report the handset sent, and the issues read from it now.

Issues are derived on each read, so offline and every age are true when looked at. Admin only, because the report describes a handset in the fleet.

Board session token, admin role
Parameters
ParameterInTypeDescription
device_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404Device not found
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  "device_id" "device-000001"
  "label" "<label>"
  "last_seen_at" "2026-09-23T10:15:00+01:00"
  "reported_at" "2026-09-23T10:15:00+01:00"
  "app_version" "<app_version>"
  "android_version" "<android_version>"
  "android_sdk" 1
  "manufacturer" "<manufacturer>"
  "model" "<model>"
  "battery" 1
  "network" "<network>"
  "battery_exempt" 
  "issues" 
    
      "code" "accessibility_off"
      "severity" "critical"
      "message" "Turn on the accessibility service for Nexus Agent."
    
  
  "report" 
SIMs

SIM inventory and provisioning routes require a Board session token belonging to an admin. PATCH /sims/{sim_id}/balance remains machine-key authenticated.

GET/sims

List registered SIMs.

Board session token, admin role
Query parameters
ParameterInTypeDescription
unassignedqueryboolean or nulltrue returns only cards in no device, which is the pool to assign from. false returns only cards in a device. Omitted returns every SIM.

Response 200: Array of SIM objects. device_id and subscription_slot are null on an unassigned card.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
Response 200
json

  
    "sim_id" "sim-000001"
    "id" "id-000001"
    "operator" "mtn"
    "msisdn" "670000001"
    "status" "confirmed"
    "device_id" "device-000001"
    "subscription_slot" 1
    "balance_mirror" 1
    "balance_updated_at" "2026-09-23T10:15:00+01:00"
    "low_balance_threshold" 1
    "high_balance_threshold" 1
    "created_at" "2026-09-23T10:15:00+01:00"
  
GET/sims/{sim_id}

Return detail for a single SIM.

Board session token, admin role

Response 200: SIM object.

CodeCondition
200SIM found
401Missing or invalid credentials
403Admin role required
404SIM not found
Parameters
ParameterInTypeDescription
sim_idpathstring
Response 200
json

  "sim_id" "sim-000001"
  "id" "id-000001"
  "operator" "mtn"
  "msisdn" "670000001"
  "status" "confirmed"
  "device_id" "device-000001"
  "subscription_slot" 1
  "balance_mirror" 1
  "balance_updated_at" "2026-09-23T10:15:00+01:00"
  "low_balance_threshold" 1
  "high_balance_threshold" 1
  "created_at" "2026-09-23T10:15:00+01:00"
POST/sims

Register a SIM. The PIN is encrypted with AES-256-GCM and stored; plaintext is never persisted.

device_id and subscription_slot are optional and must be supplied together. Omitting both registers the card into the unassigned pool, which is the natural state for a SIM that arrives before the handset that will hold it. Assign it later with PATCH /sims/{sim_id}/assignment.

Board session token, admin role
Request body
json

  "operator" "mtn"
  "msisdn" "670000001"
  "pin" "1234"
  "device_id" "a1b2c3d4e5f6a7b8"
  "subscription_slot" 0
FieldTypeRequiredDescription
operatorstringyes"mtn" or "orange".
msisdnstringyesSIM phone number (local format). Must be unique.
pinstringyesSIM PIN (4-8 digits). Encrypted before storage; plaintext never persisted.
device_idstring or nullnoID returned by POST /devices. Omit, together with subscription_slot, to leave the card unassigned.
subscription_slotstring or nullnoPhysical SIM slot: 0 for SIM 1, 1 for SIM 2. Used by the Android app to select the correct SIM when dialling USSD. Required when device_id is given.
Constraints
  • A device may have at most 2 SIMs (one per slot). Returns 409 if the device already has 2 SIMs or if the chosen slot is already occupied.
  • Supplying one of device_id and subscription_slot without the other returns 422. A slot is meaningless without the device that owns it.
Response 201
json

  "sim_id" "sim-abc123def456"
  "operator" "mtn"
  "msisdn" "670000001"
  "device_id" "a1b2c3d4e5f6a7b8"
  "created_at" "2026-08-07T10:06:00+01:00"

sim_id is server-generated. It identifies the SIM within Nexus; it is not sent when creating operations (use operator instead).

StatusMeaning
201SIM registered
404device_id was given but no such device exists
409MSISDN already exists, device already has 2 SIMs, slot already occupied, or the device is not active
422device_id and subscription_slot were not supplied together
Status codes
CodeCondition
201Created
401Missing or invalid credentials
403Admin role required
404device_id was given but no such device exists
409MSISDN already exists, device already has 2 SIMs, slot already occupied, or the device is not active
422device_id and subscription_slot were not supplied together
PATCH/sims/{sim_id}

Update SIM configuration or alert threshold.

Board session token, admin role
Request body (all fields optional)
json

  "status" "frozen"
  "low_balance_threshold" 50000
  "high_balance_threshold" 500000
FieldTypeRequiredDescription
statusstring or nullno"active", "frozen", or "retired".
low_balance_thresholdinteger or nullnoAlert when balance falls below this value (FCFA).
high_balance_thresholdinteger or nullnoOptional upper alert threshold (FCFA).

Response 200: Updated SIM object.

Parameters
ParameterInTypeDescription
sim_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404SIM not found
Response 200
json

  "sim_id" "sim-000001"
  "operator" "mtn"
  "msisdn" "670000001"
  "device_id" "device-000001"
  "created_at" "2026-09-23T10:15:00+01:00"
DELETE/sims/{sim_id}

Permanently delete a SIM. Operations referencing this SIM are preserved with sim_id nulled. Evidences and daily reconciliations are deleted with the SIM.

Board session token, admin role

Response 204: No body.

CodeCondition
204Deleted
401Missing or invalid credentials
403Admin role required
404SIM not found

Deleting a SIM is permanent and destroys its evidence. To take a card out of a handset without losing its history, unassign it instead.

Parameters
ParameterInTypeDescription
sim_idpathstring
PATCH/sims/{sim_id}/assignment

Put a SIM into a device slot.

Handles both assignment and moving. A card already in another device is moved rather than refused, which is the common case when a handset fails and its cards go into a replacement. The SIM keeps its measured balance across a move, since the balance belongs to the card rather than to the handset, so a moved card runs operations immediately without remeasuring.

Board session token, admin role
Request body
json

  "device_id" "a1b2c3d4e5f6a7b8"
  "subscription_slot" 0
FieldTypeRequiredDescription
device_idstringyesTarget device.
subscription_slotstringyes0 for SIM 1, 1 for SIM 2.
Response 200
json

  "sim_id" "sim-abc123def456"
  "device_id" "a1b2c3d4e5f6a7b8"
  "subscription_slot" 0
CodeCondition
200Assigned or moved
401Missing or invalid credentials
403Admin role required
404SIM or device not found
409Device already has 2 SIMs, the slot is occupied, or the device is not active

The SIM being moved is excluded from the occupancy checks, so re-slotting a card already on the target device is not refused by its own presence.

Parameters
ParameterInTypeDescription
sim_idpathstring
DELETE/sims/{sim_id}/assignment

Return a SIM to the unassigned pool.

The card keeps its MSISDN, measured balance, evidence and operation history. It runs no operations while unassigned, since a card in no handset cannot dial.

Board session token, admin role
Response 200
json

  "sim_id" "sim-abc123def456"
  "device_id" 
  "subscription_slot" 
CodeCondition
200Unassigned
401Missing or invalid credentials
403Admin role required
404SIM not found
409The SIM is already unassigned, or has operations queued or running

Work in flight blocks unassignment, because those operations name a device that would no longer hold the card. Wait for them to finish.

Parameters
ParameterInTypeDescription
sim_idpathstring
PATCH/sims/{sim_id}/balance

Record a measured balance for a SIM. Authenticated with X-API-Key, not a board token: recording a balance is routine automation on the same path that creates operations.

Both balance columns are written in one transaction. That coupling is the reason the endpoint exists: a current figure carrying an old timestamp is refused as stale, with no visible cause.

Request body
json

  "balance" 812300
  "observed_at" "2026-08-25T14:22:00Z"
  "source" "n8n"

observed_at is optional and defaults to now. Supply it when the reading was taken earlier, so it is not recorded as fresher than it is. A future timestamp is rejected.

source is optional and records where the reading came from, so an unexpected balance can be traced.

Response 200
json

  "sim_id" "sim-abc123def456"
  "balance_mirror" 812300
  "balance_updated_at" "2026-08-25T14:22:00Z"
  "balance_source" "n8n"
CodeCondition
200Recorded
401Missing or invalid credentials
404SIM not found
422Negative balance, or observed_at in the future

Writes an audit_log row carrying the previous and new values.

Machine key, X-API-Key
Parameters
ParameterInTypeDescription
sim_idpathstring
Request fields
FieldTypeRequiredDescription
balanceintegeryesMeasured balance in FCFA. At least 0
observed_atdatetime or nullno
sourcestring or nullnoWho recorded the reading. Defaults to api.. One of sms, balance_check, api, n8n
GET/sims/{sim_id}/history

Return operation history and volume stats for a SIM.

Board session token, admin role
Response 200
json

  "sim_id" "mtn-01"
  "total_operations" 142
  "total_confirmed" 139
  "total_failed" 2
  "total_no_evidence" 1
  "balance_mirror" 812300
  "operations" ...
Parameters
ParameterInTypeDescription
sim_idpathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
404SIM not found
Transactions

Transfers received from intake, waiting for an agent to launch them. Launching creates an operation, after which the transfer follows the normal path and the operation owns its outcome.

Ownership of the table is split. Intake owns the transfer details and never writes the board's columns. The board owns state, launched_by, launched_at, operation_id and cancelled_reason, and rewrites only receiver_name and receiver_phone. A sync writing both sides would overwrite an agent's work, and a launched transfer would reappear as pending.

StateMeaning
pendingReceived from intake, nobody has launched it
launchedAn agent launched it, the operation exists
doneThe launched operation reached a terminal state
cancelledSet aside without being launched

state is deliberately separate from an operation's status. It records whether anyone picked the transfer up; the operator's verdict belongs to the operation. Two columns holding one fact diverge the first time a write fails.

GET/transactions

List transactions the caller may act on.

An agent sees every pending transaction, because the queue is shared and whoever is free takes the next one, plus the ones it launched itself. It never sees another agent's launched work. An admin sees everything, with launched_by_name resolved.

The scope comes from the token, so an agent cannot read another agent's work by asking for it.

Board session token
Query parameters
ParameterInTypeDescription
statequerystring or nullFilter by state: pending, launched, done, cancelled. An unknown value returns 422
limitqueryintegerMax results. Default 100, max 250

Response 200: Array of transaction summaries, newest first.

Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422Unknown state value
Response 200
json

  
    "id" "3f2c0000-0000-4000-8000-000000000001"
    "request_id" "wego-20260923-000001"
    "contact_id" "contact-000001"
    "customer_name" "Awa N."
    "customer_phone" "670000001"
    "receiver_name" "Awa N."
    "receiver_phone" "670000001"
    "operator" "mtn"
    "received_amount_xaf" 25000
    "wego_verify" 
    "transaction_date" "2026-09-23T10:15:00+01:00"
    "state" "pending"
    "launched_by" "3f2c0000-0000-4000-8000-000000000001"
    "launched_by_name" "Awa N."
    "launched_at" "2026-09-23T10:15:00+01:00"
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "outcome" "confirmed"
    "settled_at" "2026-09-23T10:15:00+01:00"
    "settled_by" "3f2c0000-0000-4000-8000-000000000001"
    "settled_by_name" "Awa N."
    "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
    "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
    "created_at" "2026-09-23T10:15:00+01:00"
  
GET/transactions/{transaction_id}

Return one transaction in full, including the customer side and the rate, so an agent can compare what the customer paid against what will be sent.

Board session token

A non-admin may read a pending transaction and its own launched work. Another agent's launched transaction returns 404 rather than 403, so the response does not confirm the row exists.

CodeCondition
200Found
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Not found, or not visible to this caller
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
PATCH/transactions/{transaction_id}

Correct the receiver before launching.

Board session token
Request body
json

  "receiver_name" "Samuel Nkolo"
  "receiver_phone" "677000506"
  "contact_id" "contact-000001"

Both fields are optional, and no other field is accepted. A typo in the name or the number is the common case, and the operator's name check catches a wrong pairing before money moves.

The amount is not editable. It has to match what the customer paid, so a wrong amount is cancelled and reissued by intake rather than adjusted at the counter.

CodeCondition
200Corrected
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Not found
409The transaction is not pending. Its operation already carries the details
422Neither field was supplied
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Request fields
FieldTypeRequiredDescription
receiver_namestring or nullnoUp to 200 characters
receiver_phonestring or nullnoUp to 20 characters
contact_idstring or nullnoUp to 200 characters
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
POST/transactions/{transaction_id}/launch

Create the operation this transaction describes.

Board session token

The operation is built from the transaction: receiver_phone becomes the client msisdn, receiver_name the client name, received_amount_xaf the amount, contact_id is carried through so the evidence reaches the customer, and wego_verify becomes wego_verified. The SIM is selected by the API, as for any operation.

Response 200
json

  "transaction_id" "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  "operation_id" "a1b2c3d4-5678-90ab-cdef-1234567890ab"
  "state" "launched"
CodeCondition
200Launched, the operation exists
401Missing or invalid credentials
403The caller's role or access does not allow this
404Not found
409Already launched or cancelled. Another agent may have launched it
422No SIM available, no measured balance, or the selected SIM cannot cover the amount
423The device holding the selected SIM is frozen

Launching twice would pay a customer twice, so three things prevent it.

The claim is a conditional update on state, committed before the operation is created. A second caller finds no pending row to claim and is refused.

The operation's request_id is derived from the transaction, tx-{transaction_id}, rather than generated per call. operations.request_id is unique, so a second launch cannot create an operation even if it got past the claim. This is what prevents an orphan: with a random id, a second launch created a real queued operation before anything refused it.

A unique partial index on transactions.operation_id refuses a second attempt to attach an operation to one transaction.

A refused launch leaves the transaction pending. A 422 or 423 means the transfer never reached the operator, and it still has to go out, so the claim is released and the row returns to the queue. A 409 does not release it, because that launch succeeded.

Parameters
ParameterInTypeDescription
transaction_idpathUUID
POST/transactions/{transaction_id}/cancel

Set a transaction aside without launching it.

Board session token, any agent

Cancelling decides that the customer is not paid, including when the amount is wrong. Any agent may do it, because the agent working the queue is the one who learns the customer never paid.

Terminal. Nothing returns a cancelled transaction to pending, so the transfer can never be launched afterwards.

Request body
json

  "reason" "Wrong amount, intake reissuing"
CodeCondition
200Cancelled
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Not found
409The transaction is not pending. A launched operation is cancelled on the operation, not here
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Request fields
FieldTypeRequiredDescription
reasonstringyesUp to 500 characters
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
POST/transactions

Write a transfer into the launch queue.

The machine key, because intake is n8n rather than a person. It writes only the details it owns: state, launched_by, launched_at and operation_id are absent from the body, so a caller cannot put a launched transfer back in the queue and have it paid twice.

request_id is unique. Replaying the same transfer is refused rather than queued again, which is what makes the call safe to retry.

The operator is checked against the number rather than trusted. The mark on the receipt is drawn from the prefix, so the two disagreeing would print a proof of nothing.

Machine key, X-API-Key
Request body
json

  "request_id" "wego-20260923-000001"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "rate" 655.957
  "send_amount_eur" 1.5
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
Request fields
FieldTypeRequiredDescription
request_idstringyesUp to 200 characters
parent_request_idstring or nullnoUp to 200 characters
master_transaction_idstring or nullnoUp to 200 characters
contact_idstringyesUp to 200 characters
customer_namestring or nullnoUp to 200 characters
customer_phonestring or nullnoUp to 20 characters
receiver_namestringyesUp to 200 characters
receiver_phonestringyesUp to 20 characters
operatorstringyesOne of mtn, orange
received_amount_xafintegeryesAbove 0
ratenumber or nullnoAbove 0
send_amount_eurnumber or nullnoAbove 0
wego_verifybooleannoDefault false
transaction_datedatetimeyes
Status codes
CodeCondition
201Created
401Missing or invalid credentials
409A transaction with this id already exists
422The receiver phone is not a Cameroon mobile money number
Response 201
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
POST/transactions/{transaction_id}/payment

Record that the customer has paid, which a launch requires.

Intake cannot filter unpaid transfers out of the queue: the confirmation arrives separately and manually, so a row exists before the money does.

Any agent may confirm, since whoever takes the payment knows it arrived. Confirming twice returns the existing confirmation rather than overwriting it.

Board session token
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Transaction not found
409The transaction is not pending. Payment can only be confirmed before launching
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
DELETE/transactions/{transaction_id}/payment

Undo a payment confirmation on a pending transaction.

Confirming by mistake would otherwise leave a transfer launchable with no money behind it, and cancelling the whole transaction is too blunt a correction.

Only while pending. A launched transfer has already been sent, so withdrawing the confirmation would misreport what happened.

Board session token
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Transaction not found
409The transaction is not pending. The transfer has already been sent
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
POST/transactions/{transaction_id}/settle-by-receipt

Close a pending transaction that was covered outside Nexus.

A partner made the transfer while Nexus was down or a SIM was empty, and the agent has issued the customer a receipt for it. The transfer is finished, so it leaves the launch queue rather than sitting there waiting for a launch that must never happen: launching it now would pay the recipient twice.

Terminal, like cancelling. It never returns to pending and no operation is created, so nothing later settles it a second time.

Only pending. A launched transaction already has an operation and settles through that, and a done one keeps its first result.

Board session token
Parameters
ParameterInTypeDescription
transaction_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Transaction not found
409The transaction is not pending. Only a transaction nobody has launched can be closed by a receipt
Response 200
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "contact_id" "contact-000001"
  "customer_name" "Awa N."
  "customer_phone" "670000001"
  "receiver_name" "Awa N."
  "receiver_phone" "670000001"
  "operator" "mtn"
  "received_amount_xaf" 25000
  "wego_verify" 
  "transaction_date" "2026-09-23T10:15:00+01:00"
  "state" "pending"
  "launched_by" "3f2c0000-0000-4000-8000-000000000001"
  "launched_by_name" "Awa N."
  "launched_at" "2026-09-23T10:15:00+01:00"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "outcome" "confirmed"
  "settled_at" "2026-09-23T10:15:00+01:00"
  "settled_by" "3f2c0000-0000-4000-8000-000000000001"
  "settled_by_name" "Awa N."
  "payment_confirmed_at" "2026-09-23T10:15:00+01:00"
  "payment_confirmed_by" "3f2c0000-0000-4000-8000-000000000001"
  "created_at" "2026-09-23T10:15:00+01:00"
  "parent_request_id" "parent_request-000001"
  "master_transaction_id" "master_transaction-000001"
  "rate" "655.957"
  "send_amount_eur" "38.11"
  "cancelled_reason" "<cancelled_reason>"
  "updated_at" "2026-09-23T10:15:00+01:00"
Analytics

What Nexus moved over a window, and how reliably it moved it. One read, one window, one caller.

Scoped as every other board read is: an agent sees its own work, a supervisor and an admin see the fleet. fleet on the response says which, so a screen can label a total rather than leaving a reader to guess whose it is.

Two blocks are fleet only. activity.by_agent is empty for an agent, because showing it publishes a ranking of colleagues, and money.realised_rate is null, because a transaction names no agent until it is launched and so cannot be attributed.

Reads only. Nothing here writes, so it is safe to poll.

GET/analytics
Board session token
Query parameters
ParameterInTypeDescription
daysqueryinteger or nullLocal calendar days back from this midnight. Default 7, min 1, max 365. days=1 is today
fromquerystring or nullYYYY-MM-DD, inclusive. Wins over days
toquerystring or nullYYYY-MM-DD, inclusive. The window ends at the midnight after it, so a whole day is covered
allquerybooleanReach every operation, past the days ceiling.

A reversed range and a range starting in the future are both refused with 422.

Windowing. money, activity and reliability cover the window. load never does: it is what is waiting on a person right now, and a queue that answered for last week would be read as today's. A screen showing both must say so.

The daily series carries every calendar day in the window, including the ones nothing happened on. A grouped query returns only days with rows, so a quiet day would be absent and a line drawn through the gap would show movement across a day that had none. An open ended window ends at today rather than at the last day that carried work. Past 120 days the series buckets by week, dated by the Monday opening each one, since a row per day for a year is neither drawable nor useful.

Response 200
FieldTypeDescription
fleetbooleanWhether the figures cover everyone or only the caller
sincedatetime, nullWindow start. Null when unbounded
untildatetime, nullWindow end. Null when unbounded
moneyobjectValue moved, and what it cost
activityobjectHow much work ran, and when
reliabilityobjectWhether the system confirmed on its own, and how fast
loadobjectWhat is waiting on a person right now
money
FieldTypeDescription
sent_xafintegerConfirmed value. Balance checks are excluded, since they carry a placeholder amount of 1 and would inflate volume by the number of times a SIM was measured
transfersintegerConfirmed transfers behind that value
median_transferintegerMedian confirmed amount
largest_transferintegerLargest single confirmed amount
eur_takennumberEUR received from intake
received_xafintegerXAF against that EUR
realised_ratenumber, nullXAF per EUR as it happened rather than as configured. Fleet only
fees_xafintegerOperator fees read from confirming messages
fees_known_forintegerConfirmed transfers whose message carried a readable fee. Reported beside the total so a zero is legible: fee extraction landed after most rows were stored
at_risk_xafintegerValue waiting on a human decision
at_risk_countintegerTransfers behind it
lost_xafintegerRefused by the operator, or settled as never arrived
lost_countintegerTransfers behind it
activity
FieldTypeDescription
operationsintegerEvery operation, balance checks included
transfersintegerMoney movements only
balance_checksintegerMeasurements only
active_simsintegerSIMs that ran something
active_agentsintegerAgents who ran something
dailyarrayOne entry per calendar day: day, operations, transfers, balance_checks, value_xaf, confirmed, auto_confirmed, failed, unproven
by_weekdayarrayweekday as Monday 1 through Sunday 7, and operations
by_hourarrayweekday, hour in local time, and operations
by_agentarrayagent_id, name, operations, value_xaf. Empty for an agent
reliability
FieldTypeDescription
confirmedintegerOperations that reached confirmed
auto_confirmedintegerConfirmed with nobody arbitrating or attaching. A manual attachment records who did it, so it is not counted here
auto_confirm_ratenumber, nullauto_confirmed over confirmed. Null when nothing confirmed
settled_by_handintegerSomebody decided the outcome
failedintegerThe operator refused
unprovenintegerNo proof arrived inside the window
proof_delay_median_snumber, nullSeconds from a message reaching Nexus to it being paired. This is processing time, not the customer's wait
proof_delay_p90_snumber, nullThe slowest tenth of the same measure
total_messagesintegerMessages received. Scoped through the operation, so an agent counts its own
ambiguous_messagesintegerMessages that could match more than one transfer
collision_ratenumber, nullAmbiguous over total. Null when no messages arrived
thresholdsobjectevidence_window_s and late_evidence_s, from configuration, so a client judges a delay against the bounds the matcher enforces rather than against its own
by_statusobjectEvery status with a count. Sent so no state has to be inferred by subtraction: the remainder of the total minus the terminal states holds live work, not cancellations

load: never windowed.

FieldTypeDescription
unproven_waitingintegerTransfers awaiting a decision
messages_waitingintegerMessages awaiting a pairing
oldest_unproven_hoursnumber, nullAge of the oldest waiting transfer
oldest_message_hoursnumber, nullAge of the oldest unpaired message
dismissedintegerMessages set aside in the window
settledintegerTransfers decided in the window
sims_unmeasuredintegerNo measured balance, so they run nothing but a balance check
sims_staleintegerMeasured, but not recently
sims_frozenintegerFrozen
sims_unassignedintegerOn no device
Errors
StatusMeaning
401No board session token
422A reversed range, or a range starting in the future
Example
json

  "fleet" 
  "since" "2026-09-04T00:00:00+01:00"
  "until" "2026-09-11T00:00:00+01:00"
  "money" 
    "sent_xaf" 4260000
    "transfers" 52
    "median_transfer" 50000
    "largest_transfer" 750000
    "eur_taken" 6494.21
    "received_xaf" 4260000
    "realised_rate" 655.96
    "fees_xaf" 8940
    "fees_known_for" 48
    "at_risk_xaf" 132800
    "at_risk_count" 2
    "lost_xaf" 0
    "lost_count" 0
  
  "activity" 
    "operations" 61
    "transfers" 52
    "balance_checks" 9
    "active_sims" 4
    "active_agents" 3
    "daily" 
       "day" "2026-09-10" "operations" 12 "transfers" 11 "balance_checks" 1 "value_xaf" 840000 "confirmed" 10 "auto_confirmed" 9 "failed" 1 "unproven" 0 
    
    "by_weekday"  "weekday" 4 "operations" 12 
    "by_hour"  "hour" 14 "operations" 7 
    "by_agent"  "agent_id" "3f2c0000-0000-4000-8000-000000000001" "full_name" "Samuel Nkolo" "operations" 34 "value_xaf" 2180000 
  
  "reliability" 
    "confirmed" 48
    "auto_confirmed" 44
    "auto_confirm_rate" 0.88
    "settled_by_hand" 4
    "failed" 3
    "unproven" 1
    "proof_delay_median_s" 42
    "proof_delay_p90_s" 118
    "total_messages" 57
    "ambiguous_messages" 2
    "collision_rate" 0.035
    "thresholds"  "auto_confirm_rate" 0.9 "proof_delay_p90_s" 180 "collision_rate" 0.05 
    "by_status"  "queued" 0 "running" 0 "awaiting_evidence" 1 "awaiting_manual_dial" 0 "confirmed" 48 "failed" 3 "no_evidence" 1 "rejected" 8 
  
  "load" 
    "unproven_waiting" 1
    "messages_waiting" 4
    "oldest_unproven_hours" 3.2
    "oldest_message_hours" 21.5
    "dismissed" 2
    "settled" 4
    "sims_unmeasured" 0
    "sims_stale" 1
    "sims_frozen" 0
    "sims_unassigned" 0
  

by_agent is empty and realised_rate is null for a non-admin caller, as described above. load is never windowed while the other three blocks are.

Status codes
CodeCondition
200Success
401No board session token
403The agent is inactive or no longer exists
422A reversed range, or a range starting in the future
Response 200
json

  "fleet" 
  "since" "2026-09-23T10:15:00+01:00"
  "until" "2026-09-23T10:15:00+01:00"
  "money" 
    "sent_xaf" 1
    "transfers" 1
    "median_transfer" 1
    "largest_transfer" 1
    "eur_taken" 1.5
    "received_xaf" 1
    "realised_rate" 655.957
    "fees_xaf" 1
    "fees_known_for" 1
    "at_risk_xaf" 1
    "at_risk_count" 1
    "lost_xaf" 1
    "lost_count" 1
  
  "activity" 
    "operations" 1
    "transfers" 1
    "balance_checks" 1
    "active_sims" 1
    "active_agents" 1
    "daily" 
      
        "day" "<day>"
        "operations" 1
        "transfers" 1
        "balance_checks" 1
        "value_xaf" 25000
        "confirmed" 1
        "auto_confirmed" 1
        "failed" 1
        "unproven" 1
      
    
    "by_weekday" 
      
        "weekday" 1
        "operations" 1
      
    
    "by_hour" 
      
        "weekday" 1
        "hour" 1
        "operations" 1
      
    
    "by_agent" 
      
        "agent_id" "agent-000001"
        "name" "Awa N."
        "operations" 1
        "value_xaf" 25000
      
    
  
  "reliability" 
    "confirmed" 1
    "auto_confirmed" 1
    "auto_confirm_rate" 655.957
    "settled_by_hand" 1
    "failed" 1
    "unproven" 1
    "proof_delay_median_s" 1.5
    "proof_delay_p90_s" 1.5
    "total_messages" 1
    "ambiguous_messages" 1
    "collision_rate" 655.957
    "thresholds" 
      "evidence_window_s" 1
      "late_evidence_s" 1
    
    "by_status" 
  
  "load" 
    "unproven_waiting" 1
    "messages_waiting" 1
    "oldest_unproven_hours" 1.5
    "oldest_message_hours" 1.5
    "dismissed" 1
    "settled" 1
    "sims_unmeasured" 1
    "sims_stale" 1
    "sims_frozen" 1
    "sims_unassigned" 1
  
  "previous" 
    "sent_xaf" 1
    "transfers" 1
    "confirmed" 1
    "auto_confirmed" 1
    "since" "2026-09-23T10:15:00+01:00"
    "until" "2026-09-23T10:15:00+01:00"
    "median_transfer" 0
    "largest_transfer" 0
    "realised_rate" 655.957
  
GET/analytics/evidence-health

Operations that dialled and whose confirming message never arrived, grouped by handset.

Capture on the device cannot be made total: a preloaded messaging app can take the broadcast first, an aggressive OEM can force stop the agent, and a message arriving before the first unlock after a reboot reaches no receiver at all. This is the server's own account of what never came, rather than a report the handsets make about themselves.

Board session token, admin role. It names handsets and the agents behind them, which is a fleet view.

Query parameters: days, from and to, exactly as GET /analytics reads them.

Response 200
FieldTypeDescription
since, untilstringThe window read
finishedintegerOperations that reached confirmed or no_evidence inside it
confirmedintegerOf those, the ones whose message arrived
no_evidenceintegerOf those, the ones whose message never did
loss_ratenumberno_evidence over finished, 0 to 1
devicesarrayOne row per handset, worst first

Each device row carries device_id, label, last_seen_at, the same four counts, baseline_rate, and degraded.

What degraded means. The device lost a materially larger share than it did over the preceding fortnight: at least twice its own baseline, and at least ten percent. A device with too little history is judged on the window alone and named only above half. Either way at least five finished operations are required, since two failures out of three says more about the sample than the handset.

Judged against the device's own history rather than a fixed threshold on purpose. An absolute threshold names every handset during an operator outage, which is how a report stops being read, and stays quiet while a single device drifts, which is the failure worth catching.

Counted on finished_at, so an operation still in awaiting_evidence is not counted as lost. It has not failed yet, and counting it would report a loss the moment a dial completed.

Parameters
ParameterInTypeDescription
daysqueryinteger or null1 to 365. Default 7
fromquerystring or null
toquerystring or null
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Admin role required
422The from day is after the to day
Response 200
json

  "since" "2026-09-23T10:15:00+01:00"
  "until" "2026-09-23T10:15:00+01:00"
  "finished" 1
  "confirmed" 1
  "no_evidence" 1
  "loss_rate" 655.957
  "devices" 
    
      "device_id" "device-000001"
      "label" "<label>"
      "last_seen_at" "2026-09-23T10:15:00+01:00"
      "finished" 1
      "confirmed" 1
      "no_evidence" 1
      "loss_rate" 655.957
      "baseline_rate" 655.957
      "degraded" 
    
  
Receipts
GET/operations/{operation_id}/receipt

Render one confirmed transfer as a receipt the customer can be shown.

X-API-Key

The document is generated on demand and never stored, so there is nothing to expire, back up or leak. n8n calls this and forwards the bytes.

Request
bash
curl -X GET "https://api.nexus.sawego.com/v1/operations/{operation_id}/receipt?format=pdf&theme=dark&lang=fr" \
  -H "X-API-Key: $NEXUS_API_KEY" \
  --output receipt.pdf
Request headers
HeaderRequiredValue
X-API-KeyyesThe machine key. No board session token is accepted.

No request body. Every option is a query parameter.

Query parameters
ParameterInTypeDescription
operation_idpathUUID
formatquerystringpng, pdf. The PDF is one page sized to the receipt itself, not a receipt on a sheet of A4.
themequerystringlight, dark.
langquerystringen, fr. Sets every label, the date, and the closing lines.

Anything else is refused with 422 rather than quietly falling back, so a typo in an n8n expression surfaces instead of shipping the wrong document to a customer.

Response headers
HeaderValue
Content-Typeimage/png or application/pdf, matching format
Content-Dispositioninline; filename="WeGo_Trans-2026-09-11-1423-670000123-CT4821.png". Date, minute, beneficiary number, then contact_id when the operation carries one. No customer name, since a file name travels through attachments and share logs.
Cache-Controlno-store. The document is generated per request and never stored.

Response 200: the raw image or PDF bytes. Around 350KB for a PNG and 270KB for a PDF.

CodeConditionBody
200The receiptImage or PDF bytes
401No API key{"detail": "..."}
404No such operation{"detail": "Operation {id} not found."}
409The operation cannot support a receipt{"detail": {"reason": "...", "message": "..."}​}
422An unknown format, theme or langFastAPI validation error
429Too many requests from this caller; retry after the Retry-After header
503The renderer is unavailable, the transfer is unaffected{"detail": {"reason": "renderer_unavailable", "message": "..."}​}
POST/operations/{operation_id}/receipt

Render a receipt, supplying detail the operation does not hold. Same query parameters and same response as the GET.

X-API-Key

Only an aggregated transfer carries a sender and a corridor, because only intake writes a transactions row. A caller holding that detail elsewhere, keyed on contact_id for instance, supplies it here rather than shipping a receipt with the rows missing.

Request body: optional. Every field is optional in turn.

json

  "recipient_name" "WeGo Send Beneficiary"
  "sender_name" "WeGo Send Client"
  "sender_msisdn" "+49 000 0000 0000"
  "sent_amount_eur" 381.50
  "rate_xaf_per_eur" 655.96
FieldTypeRequiredDescription
recipient_namestring or nullnoThe beneficiary name, when the operation carries none
sender_namestring or nullnoThe sender, absent outside intake
sender_msisdnstring or nullnoThe sender's number
sent_amount_eurnumber or nullnoThe amount the customer paid, greater than zero
rate_xaf_per_eurnumber or nullnoThe corridor rate, greater than zero

What the body cannot change. The amount in XAF, the operator reference, the confirmation time, the operator and the beneficiary number are read from the operation and its evidence. A field the operation already holds is refused with 422 rather than ignored, so a workflow that believes it set a value is told otherwise. An unknown field is refused the same way.

json

  "detail" 
    "reason" "conflicts_with_operation"
    "message" "These fields are already recorded on the operation and cannot be replaced: recipient_name."
  

A receipt is proof shown to a customer, so what the operator reported stays as recorded. The body fills gaps and never overwrites.

Parameters
ParameterInTypeDescription
operation_idpathUUID
formatquerystringOne of png, pdf. Default "png"
themequerystringOne of light, dark. Default "light"
langquerystringOne of en, fr. Default "en"
Request body
json

  "recipient_name" "Awa N."
  "sender_name" "Awa N."
  "sender_msisdn" "670000001"
  "sent_amount_eur" 1.5
  "rate_xaf_per_eur" 655.957
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Operation not found
409Conflicts with the current state
422The request failed validation
429Too many requests from this caller; retry after the Retry-After header
503A dependency this route needs is unavailable
POST/operations/{operation_id}/receipt/published

Record where a rendered receipt was stored.

X-API-Key

The API renders on demand and keeps nothing, so a copy in object storage belongs to whoever put it there. This records the link. The customer, the beneficiary and the amount stay on the operation and the transaction rather than being copied here, where a second copy would drift from the transfer it describes.

Request body
json

  "url" "https://r2.example.com/receipts/abc.png"
  "format" "png"
  "language" "fr"
  "theme" "light"
  "published_by" "n8n"
FieldTypeRequiredDescription
urlstringyesWhere the file lives, up to 2048 characters
formatstringnopng or pdf, defaulting to png
languagestringnoen or fr, defaulting to en
themestringnolight or dark, defaulting to light
published_bystring or nullnoWhoever wrote the file, so a wrong link is traceable

Publishing the same rendering again replaces the link rather than adding a row, so a workflow that retries is safe to run twice. Another format, language or theme for the same transfer is its own row.

CodeCondition
201Recorded. Returns the row joined to its transfer
401No API key
404No such operation
422An unknown field, or a format, language or theme outside the allowed values
429Too many requests from this caller; retry after the Retry-After header
Parameters
ParameterInTypeDescription
operation_idpathUUID
Response 201
json

  "id" "3f2c0000-0000-4000-8000-000000000001"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "url" "https://api.nexus.sawego.com/v1/..."
  "format" "<format>"
  "language" "<language>"
  "theme" "<theme>"
  "published_by" "<published_by>"
  "created_at" "2026-09-23T10:15:00+01:00"
  "recipient_name" "Awa N."
  "recipient_msisdn" "670000001"
  "amount_xaf" 25000
  "operator_ref" "<operator_ref>"
  "contact_id" "contact-000001"
  "origin" "assisted"
  "sender_name" "Awa N."
  "sender_msisdn" "670000001"
GET/operations/{operation_id}/receipt/published

Every stored rendering of one transfer, newest first.

X-API-Key

Each row carries the stored link and the transfer's own detail, read from the operation rather than from the row, so a correction on the transfer reaches the caller and the two cannot disagree.

json

  
    "id" "b1c2d3e4-0000-4000-8000-000000000001"
    "operation_id" "f31e3fe4-819e-45df-b5d1-236205fa6a15"
    "url" "https://r2.example.com/receipts/abc.png"
    "format" "png"
    "language" "fr"
    "theme" "light"
    "published_by" "n8n"
    "created_at" "2026-09-11T14:23:00+00:00"
    "recipient_name" "WeGo Send Beneficiary"
    "recipient_msisdn" "670000123"
    "amount_xaf" 250000
    "operator_ref" "MP260910.1423.B47291"
    "contact_id" "CT-4821"
    "origin" "aggregated"
    "sender_name" "WeGo Send Client"
    "sender_msisdn" "+49 000 0000 0000"
  

sender_name and sender_msisdn are null outside aggregated, since only intake writes a transactions row.

Parameters
ParameterInTypeDescription
operation_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Operation not found
429Too many requests from this caller; retry after the Retry-After header
Response 200
json

  
    "id" "3f2c0000-0000-4000-8000-000000000001"
    "operation_id" "3f2c0000-0000-4000-8000-000000000001"
    "url" "https://api.nexus.sawego.com/v1/..."
    "format" "<format>"
    "language" "<language>"
    "theme" "<theme>"
    "published_by" "<published_by>"
    "created_at" "2026-09-23T10:15:00+01:00"
    "recipient_name" "Awa N."
    "recipient_msisdn" "670000001"
    "amount_xaf" 25000
    "operator_ref" "<operator_ref>"
    "contact_id" "contact-000001"
    "origin" "assisted"
    "sender_name" "Awa N."
    "sender_msisdn" "670000001"
  
POST/receipts/manual

Render a receipt from details the caller holds.

Handed back as bytes for the caller to send on. The send route does the same and hands it to the workflow instead.

Machine key or board session token
Parameters
ParameterInTypeDescription
formatquerystringOne of png, pdf. Default "png"
themequerystringOne of light, dark. Default "light"
langquerystringOne of en, fr. Default "en"
Request body
json

  "recipient_name" "Awa N."
  "recipient_msisdn" "670000001"
  "amount_xaf" 25000
  "operator" "mtn"
  "confirmed_at" "2026-09-23T10:15:00+01:00"
  "fee_xaf" 1
  "sender_name" "Awa N."
  "sender_msisdn" "670000001"
  "sent_amount_eur" 1.5
  "rate_xaf_per_eur" 655.957
  "source_kind" "standalone"
  "source_id" "3f2c0000-0000-4000-8000-000000000001"
Request fields
FieldTypeRequiredDescription
recipient_namestringyesUp to 120 characters
recipient_msisdnstringyesUp to 32 characters
amount_xafintegeryesAbove 0
operatorstringyesOne of mtn, orange
confirmed_atdatetimeyes
fee_xafinteger or nullnoAt least 0
sender_namestring or nullnoUp to 120 characters
sender_msisdnstring or nullnoUp to 32 characters
sent_amount_eurnumber or nullnoAbove 0
rate_xaf_per_eurnumber or nullnoAbove 0
source_kindstringnoOne of operation, transaction, standalone. Default "standalone"
source_idUUID or nullno
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The request failed validation
503A dependency this route needs is unavailable
POST/receipts/manual/send

Render the receipt and let the workflow deliver it.

Nexus sends nothing itself. It renders, then emits a signed receipt.send carrying the customer reference, the code and the image, and the workflow messages the customer through the channel it already uses. Keeping the messaging there means one place holds the provider, the templates and the delivery state.

The reference is required rather than looked up. Most rows carry one and the board fills it in, but a third of settled operations do not, and a receipt addressed to nobody is worse than one the agent sends by hand.

Retried like the other events an agent is told succeeded: the agent is shown that it went, so a delivery lost to one bad response would leave the customer with nothing and nobody aware of it.

Machine key or board session token
Request body
json

  "recipient_name" "Awa N."
  "recipient_msisdn" "670000001"
  "amount_xaf" 25000
  "operator" "mtn"
  "confirmed_at" "2026-09-23T10:15:00+01:00"
  "fee_xaf" 1
  "sender_name" "Awa N."
  "sender_msisdn" "670000001"
  "sent_amount_eur" 1.5
  "rate_xaf_per_eur" 655.957
  "source_kind" "standalone"
  "source_id" "3f2c0000-0000-4000-8000-000000000001"
  "customer_ref" "<customer_ref>"
Request fields
FieldTypeRequiredDescription
recipient_namestringyesUp to 120 characters
recipient_msisdnstringyesUp to 32 characters
amount_xafintegeryesAbove 0
operatorstringyesOne of mtn, orange
confirmed_atdatetimeyes
fee_xafinteger or nullnoAt least 0
sender_namestring or nullnoUp to 120 characters
sender_msisdnstring or nullnoUp to 32 characters
sent_amount_eurnumber or nullnoAbove 0
rate_xaf_per_eurnumber or nullnoAbove 0
source_kindstringnoOne of operation, transaction, standalone. Default "standalone"
source_idUUID or nullno
customer_refstringyesUp to 120 characters
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The request failed validation
Response 200
json

  "std_code" "STD-1-T-3729H6PVW8-R7"
  "customer_ref" "<customer_ref>"
  "dispatched" 
  "receipt_png_base64" "<receipt_png_base64>"
GET/receipts/std/{code}

Read a code off a receipt and find the transfer behind it.

A standalone code is answered rather than refused: it names no row by design, because a partner transfer was never in the base, and saying so is a different answer from a code that matches nothing.

Machine key or board session token
Parameters
ParameterInTypeDescription
codepathstring
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404No transfer matches this code
Response 200
json

  "code" "STD-1-T-3729H6PVW8-R7"
  "kind" "operation"
  "operation_id" "3f2c0000-0000-4000-8000-000000000001"
  "transaction_id" "3f2c0000-0000-4000-8000-000000000001"
  "request_id" "wego-20260923-000001"
  "recipient_name" "Awa N."
  "recipient_msisdn" "670000001"
  "amount_xaf" 25000
  "status" "confirmed"
POST/receipts/delivery

Record what the messaging workflow saw when it delivered a receipt.

Nexus renders and hands over; the workflow delivers. Until this arrives the board can only say a receipt went to the workflow, which is not the same as saying the customer holds it. A provider that accepts and then fails would otherwise leave the customer waiting with nobody aware.

The machine key, because the workflow reports this, not a person.

Keyed on the code rather than a row, since a standalone receipt names no row. A code nothing was issued against is refused, so a typo is caught rather than filed against nothing.

Idempotent in the sense that matters: reporting twice writes two journal rows and the latest wins, which is what lets a retry be safe.

Machine key, X-API-Key
Request body
json

  "std_code" "STD-1-T-3729H6PVW8-R7"
  "delivered" 
  "reason" "<reason>"
  "provider_message_id" "provider_message-000001"
  "occurred_at" "2026-09-23T10:15:00+01:00"
Request fields
FieldTypeRequiredDescription
std_codestringyesUp to 40 characters
deliveredbooleanyes
reasonstring or nullnoUp to 500 characters
provider_message_idstring or nullnoUp to 200 characters
occurred_atdatetime or nullno
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404No receipt was issued with this code
Response 200
json

  "std_code" "STD-1-T-3729H6PVW8-R7"
  "delivered" 
  "recorded_at" "2026-09-23T10:15:00+01:00"
GET/receipts/issued

Receipts issued by hand, newest first.

Read from the journal rather than a table, because nothing stores a receipt: the render writes the row and the code is derived from the source. That is also what lets this cover the standalone case, where a partner covered a transfer the base never held and there is no transaction or operation to list instead.

One entry per transfer rather than per render. A receipt reissued or produced and then sent wrote several rows for one code, and the customer holds one receipt.

Windowed like the queues, on when the receipt was issued. All time with a cap silently truncated, and a cap of fifty has no relation to anything a person is looking for. One control drives every queue, so a period means the same thing wherever it is set.

kinds narrows to what the caller lists. The board's section holds the transfers Nexus never ran, and an operation's receipt belongs on the operation's own row rather than in both places.

sent_only drops the renders. A receipt produced to see how it reads, or produced and then edited, proves nothing: pressing send is the moment somebody decided the transfer was real and the customer should be told. Listing every render put those beside real sends and counted them in the total.

Machine key or board session token
Parameters
ParameterInTypeDescription
limitqueryinteger1 to 250. Default 50
daysqueryinteger or null
fromquerystring or null
toquerystring or null
allquerybooleanDefault false
kindsquerystring or nullComma separated: operation, transaction, standalone.
sent_onlyquerybooleanOnly receipts somebody chose to send, not every render.
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
Response 200
json

  
    "std_code" "STD-1-T-3729H6PVW8-R7"
    "source_kind" "<source_kind>"
    "source_id" "3f2c0000-0000-4000-8000-000000000001"
    "recipient_msisdn" "670000001"
    "amount_xaf" 25000
    "operator" "mtn"
    "issued_at" "2026-09-23T10:15:00+01:00"
    "issued_by" "3f2c0000-0000-4000-8000-000000000001"
    "issued_by_name" "Awa N."
    "sent" 
    "delivered" 
    "delivery_reason" "<delivery_reason>"
    "delivered_at" "2026-09-23T10:15:00+01:00"
  
GET/receipts/issued/{kind}/{source_id}

Whether a transfer has already been given proof, and which code carries it.

Read back from the journal rather than stored on the transfer. Every render writes that row already, so nothing new is kept and a receipt issued before this route existed is still found.

The most recent, because reissuing supersedes: the customer holds the last one produced.

Machine key or board session token
Parameters
ParameterInTypeDescription
kindpathstringOne of operation, transaction
source_idpathUUID
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
Response 200
json

  "std_code" "STD-1-T-3729H6PVW8-R7"
  "issued_at" "2026-09-23T10:15:00+01:00"
  "issued_by" "3f2c0000-0000-4000-8000-000000000001"
  "issued_by_name" "Awa N."
  "sent" 
  "delivered" 
  "delivery_reason" "<delivery_reason>"
  "delivered_at" "2026-09-23T10:15:00+01:00"
What a receipt requires

A receipt is proof shown to a customer, so it refuses to assert anything the operation does not support. A 409 carries the reason as a stable string, so the caller can branch on it rather than parse a sentence.

json

  "detail" 
    "reason" "no_operator_reference"
    "message" "The operator message carried no reference, so there is nothing for the customer to quote."
  
reasonMeaningWhat to check
not_confirmedThe operation is not in confirmed. The message names the state it is actually inWait, or settle it through arbitration. A receipt before confirmation tells the customer something Nexus does not know
no_evidenceConfirmed with no operator message attachedAn operation confirmed by hand through resolve or arbitrate has no message, so no reference exists
no_operator_referenceA message is attached but carries no operator_refA parser gap. The transfer is fine, the reference was not extracted
no_recipientThe operation carries no destination numberShould not occur through the API, which requires one

The checks run in the order the transfer progresses, so the answer names the earliest thing missing rather than the last one checked.

What appears on a receipt, by origin

Every receipt carries the recipient, the amount in XAF, the operator reference, the confirmation time, and the operator mark. The remaining rows come from intake, which only aggregated operations have, because only intake creates a transactions row.

Fieldaggregatedassistedmatching
Recipient name and numberyesyesyes
Amount in XAFyesyesyes
Operator referenceyesyesyes
Sender nameyesnono
Amount sent in EURyesnono
Exchange rateyesnono

A row with no value is omitted rather than rendered empty, so an assisted receipt is a shorter document rather than one with blanks in it.

The operator mark

The mark beside the recipient is chosen from the recipient's number through the shared prefix table in app/core/msisdn.py, not from the operation's operator column. A receipt showing the MTN mark above an Orange number proves nothing, and the column records what the caller declared at creation. Numbers are accepted as 6XXXXXXXX, 237..., +237..., and with spaces. A number outside the Cameroon plan falls back to the declared operator, and an operator with no mark is named in text instead.

Webhooks
POST/webhooks/inbound

Receive an inbound operation request from n8n. Verifies HMAC-SHA256 signature, detects the operator from dest_msisdn, and creates a queued operation.

X-Nexus-Signature: <hmac-hex>

Optional header: X-Event-Id: <uuid>, which enables replay rejection for 24 hours through Redis.

Request body
json

  "request_id" "n8n-20260818-001"
  "dest_msisdn" "670000528"
  "dest_name" "Awa N."
  "amount" 25000
  "type_op" "deposit"
  "agent_id" "3f2c0000-0000-4000-8000-000000000001"
FieldTypeRequiredDescription
request_idstringyesIdempotency key.
dest_msisdnstringyesDestination MSISDN. Operator is detected automatically from the Cameroon numbering plan.
dest_namestringnoDestination account holder name.
amountintegeryesAmount in XAF.
type_opstringnoDefaults to deposit.
agent_idUUIDnoOriginating agent.
Responses
CodeConditionBody
202Operation created{ "operation_id": "uuid", "status": "queued", "operator": "mtn" }
401Missing or invalid signature{ "detail": "..." }
409Duplicate X-Event-Id or request_id{ "detail": "..." }
422Operator cannot be detected from MSISDN, or missing required field{ "detail": "..." }
Response 202
json
Outbound webhook (Nexus to n8n)

Nexus delivers a signed POST when an operation is created and when it reaches a terminal state.

Target URL. N8N_ENV selects between N8N_TEST_URL and N8N_PROD_URL. Set it to test while developing against the n8n editor canvas, and prod against an activated workflow. WEBHOOK_URL overrides both when set. Delivery is disabled when the resolved value is empty.

Headers
text
Content-Type: application/json
X-Nexus-Signature: <hmac-sha256-hex>

The signature is the HMAC-SHA256 of the raw request body, keyed with WEBHOOK_SECRET, hex encoded with no prefix. Verify against the raw bytes: re-serialising parsed JSON changes key order and whitespace, and the signature will not match.

Body
json

  "event" "operation.confirmed"
  "is_terminal" 
  "emitted_at" "2026-08-24T14:23:45.120411+00:00"
  "operation_id" "uuid"
  "request_id" "wego-20260807-000412"
  "status" "confirmed"
  "op_type" "deposit"
  "operator" "mtn"
  "sim_id" "mtn-01"
  "amount" 25000
  "dest_msisdn" "670000528"
  "dest_name" "Awa N."
  "wego_verified" 
  "sms_amounts" 25000

Every operation event carries the fields above. is_terminal distinguishes a final state from an interim one, so a consumer branches on one flag instead of tracking event names.

Eventis_terminalAdditional fields
operation.createdfalsenone
operation.confirmedtruesms_amounts, and ussd_response for balance operations
operation.failedtrueoutcome, failure_reason, ussd_response, or reason when cancelled by an agent
operation.no_evidencetrueoutcome, failure_reason, ussd_response
sms.orphanfalsesim_id, amounts, is_ambiguous. Carries no operation fields, since no operation matched.

A failed name check arrives as operation.failed with outcome: "name_mismatch". There is no separate event for it.

Delivery: 4 attempts total, with 2s, 4s and 8s pauses between them. Every attempt is recorded in webhook_deliveries.

Test Utilities

Available only when DEBUG=true. Not present in production.

POST/test/seed

Create fixed test fixtures: a test agent, device, and SIM.

X-API-Key
Fixed values
FixtureValue
Agent logintest-agent
Agent passwordtest-password-123
Device tokentest-device-token-nexus-dashboard
SIM idtest-sim-mtn-01
SIM MSISDN670000001
SIM operatormtn

Response 200: { "status": "seeded" }

Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Not found
Response 200
json
POST/test/reset

Clear all operations, evidence rows, and sessions. Does not delete agents, devices, or SIMs.

X-API-Key

Response 200: { "status": "reset" }

Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Not found
Response 200
json
GET/test/sim-state

Return the raw state the load balancer sees when selecting a SIM. Only active when DEBUG=true.

Machine key, X-API-Key
Status codes
CodeCondition
200Success
401Missing or invalid credentials
404Not found
Response 200
json
Health
GET/health

Liveness check. No auth required.

Response 200
json

If Redis is unavailable: "redis": "unavailable", "status": "degraded". The API continues to function using Postgres fallback.

None
Status codes
CodeCondition
200Success
Customers

Customer lookup for the board, read from the transfers Nexus already holds.

GET/customers

Customers matching a name or a number, for the contact_id field.

contact_id is the only record of who receives the proof and nothing validates it, so a wrong one sends the confirmation to somebody else.

A query shorter than MIN_QUERY returns nothing. One character matches most of the table, which pages through the customer list rather than searching it.

Contacts the caller has used before sort first.

Board session token
Parameters
ParameterInTypeDescription
qquerystringPart of a name or a number.
limitqueryinteger1 to 25. Default 8
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
Response 200
json

  
    "contact_id" "contact-000001"
    "name" "Awa N."
    "msisdn" "670000001"
  
Payments

Customer payments recorded against WeGoPay, which a transaction is settled from.

GET/payments

The ledger, newest first.

Every agent reads the whole ledger. A payment carries no agent of its own, and an agent checking whether money arrived needs the row whoever recorded it, so scoping this to the caller would defeat its purpose.

The window is open when no period is given, so a caller reconciling against a statement can read the whole ledger. The board asks for today, because somebody opening that screen is nearly always asking what came in today.

Machine key or board session token
Parameters
ParameterInTypeDescription
checkedqueryboolean or null
searchquerystring or nullSubstring of the sender name, case insensitive.
daysqueryinteger or nullHow far back to look, in local calendar days.
fromquerystring or nullLocal calendar day to start from, YYYY-MM-DD. Wins over days.
toquerystring or nullLocal calendar day to end on, inclusive of that whole day.
limitqueryinteger1 to 200. Default 50
offsetqueryintegerAt least 0. Default 0
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
422The from day is after the to day
Response 200
json

  "items" 
    
      "id" 1
      "created_at" "2026-09-23T10:15:00+01:00"
      "payment_id" "payment-000001"
      "sender" "<sender>"
      "account_name" "Awa N."
      "amount" 1.5
      "fees" 1.5
      "currency" "XAF"
      "account_type" "mtn_momo"
      "payment_check" 
      "source" "sms"
    
  
  "total" 1
  "limit" 1
  "offset" 1
POST/payments

Record a payment received through another channel.

Either credential records one. n8n fills the ledger from the channels it already watches, and an agent records a payment that arrived by a route nothing watches, which is the same reasoning that lets any agent cancel a pending transaction.

Machine key or board session token
Request body
json

  "payment_id" "payment-000001"
  "sender" "<sender>"
  "amount" 1.5
  "fees" 0
  "currency" "EUR"
  "account_name" "Awa N."
  "account_type" "mtn_momo"
  "payment_check" 
Request fields
FieldTypeRequiredDescription
payment_idstringyesUp to 120 characters
senderstringyesUp to 200 characters
amountnumberyesAbove 0
feesnumbernoAt least 0. Default 0
currencystringnoUp to 8 characters. Default "EUR"
account_namestring or nullnoUp to 200 characters
account_typestring or nullnoUp to 60 characters
payment_checkbooleannoDefault false
Status codes
CodeCondition
201Created
401Missing or invalid credentials
403The agent is inactive or no longer exists
409This payment is already recorded
Response 201
json

  "id" 1
  "created_at" "2026-09-23T10:15:00+01:00"
  "payment_id" "payment-000001"
  "sender" "<sender>"
  "account_name" "Awa N."
  "amount" 1.5
  "fees" 1.5
  "currency" "XAF"
  "account_type" "mtn_momo"
  "payment_check" 
  "source" "sms"
PATCH/payments/{payment_id}/check

Set whether the payment has been verified against the receiving account.

The only field on a payment that changes after the row is written. Reaching it needs its own path, so no request body can carry an amount or a sender into an update by accident.

Machine key or board session token
Parameters
ParameterInTypeDescription
payment_idpathinteger
Request body
json

  "payment_check" 
Request fields
FieldTypeRequiredDescription
payment_checkbooleanyes
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403The agent is inactive or no longer exists
404Payment not found
Response 200
json

  "id" 1
  "created_at" "2026-09-23T10:15:00+01:00"
  "payment_id" "payment-000001"
  "sender" "<sender>"
  "account_name" "Awa N."
  "amount" 1.5
  "fees" 1.5
  "currency" "XAF"
  "account_type" "mtn_momo"
  "payment_check" 
  "source" "sms"
Events

Committed changes announced to open boards as they happen, so no view waits for a poll.

GET/events/stream

Every committed change the caller may see, as a server sent event stream.

Each change event names a kind (operation, transaction, evidence, payment, receipt, sim, device) and an id, or no id when a bulk statement changed rows of that kind. It carries no data: the board refetches what it shows through the routes that scope it.

An admin hears everything. An agent hears the transaction queue, payments and receipts, its own operations and its SIMs, and nothing about devices.

Read it with fetch and the bearer header, never EventSource, so the token stays out of the URL. Send Last-Event-ID on reconnect to replay what was missed; a reset event means that could not be done and every view should refetch. The stream closes with cycle every 90 seconds and is meant to be reopened at once.

Board session token
Status codes
CodeCondition
200Success
401Missing or invalid credentials
403Agent not found or inactive
Invariants

Five rules enforced at the database level. Any implementation that violates them is incorrect.

  • 1One execution per request_id. Enforced by a UNIQUE constraint on the operations table, not by application code. A second insert raises an integrity error and returns 409.
  • 2One running operation per device. Enforced by a partial unique index on (device_id) WHERE status='running'. The database prevents a second claim regardless of concurrency.
  • 3no_evidence auto-resolves only when a matching SMS arrives within late_evidence_minutes. After that window, only POST /admin/operations/{id}/arbitrate can resolve it.
  • 4SIM PIN never leaves the vault. No log sink, n8n payload, Notion field, device database, or heartbeat payload ever contains a PIN. It exists in secret_enc (AES-256-GCM) and in API RAM during USSD dispatch only.
  • 5No SMS is deleted. Orphan rows with operation_id = NULL stay indefinitely and remain visible in the arbitration queue.
Environment Variables
VariablePurpose
DATABASE_URLSQLAlchemy connection string. Default: sqlite:///./nexus.db. Production: Supabase Postgres URL.
NEXUS_API_KEYAuthenticates n8n. Not accepted on admin routes. Header: X-API-Key.
WEBHOOK_SECRETHMAC key for outbound and inbound webhook signatures.
MASTER_KEY32-byte base64 key for AES-256-GCM PIN encryption. Never log. Never commit.
REDIS_URLRedis connection URL. Omit to run without Redis (Postgres fallback active).
N8N_ENVtest or prod. Selects which n8n URL receives webhooks. Defaults to prod.
N8N_TEST_URLn8n test path, active only while the editor canvas is listening.
N8N_PROD_URLn8n production path, requires the workflow to be activated.
WEBHOOK_URLOverrides the N8N_ENV selection. Empty everywhere disables delivery.
CORS_ALLOWED_ORIGINSComma-separated allowed origins. Default * in dev. Restrict in production.
EVIDENCE_WINDOW_MINUTESMinutes an awaiting_evidence operation waits for its first SMS before expiring to no_evidence. Default: 15.
LATE_EVIDENCE_MINUTESMinutes after expiry during which a matching SMS can still confirm a no_evidence operation. Default: 120.
DEVICE_COOLDOWN_SECONDSSeconds after a finished operation before the device can receive another claim. Default: 5.
SMS_CLOCK_SKEW_SECONDSClock difference tolerated when comparing SMS receipt time with operation start time. Default: 60.
DEBUGSet true to enable /v1/test/* routes. Never true in production.
bash
python scripts/generate-secrets.py
Nexus API v1.0 Internal · WeGo Send / Sawego Digital Contract changes target /v2