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.
| Caller | Header | Scope |
|---|---|---|
| n8n | X-API-Key: <key> | /operations, /webhooks/inbound |
| Android device | Authorization: Bearer <device_token> | /device/* only |
| Nexus Board | Authorization: Bearer <session_token> | POST /operations, GET /operations (own only), /agents/*, /devices/*, /sims/* |
| Nexus Board, admin role | Authorization: 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.
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.
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.
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.
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.
{
"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.
| Code | Meaning | Default code |
|---|---|---|
| 400 | Bad request body | bad_request |
| 401 | Missing or invalid credentials | not_signed_in |
| 403 | Signed in, but not allowed | not_allowed |
| 404 | Resource not found | not_found |
| 409 | Conflict, such as a duplicate request_id | conflict |
| 422 | Validation error or business rule refusal | refused, or invalid_request for validation |
| 423 | Circuit breaker active | locked |
| 429 | Too many requests; retry after the Retry-After header | rate_limited |
| 500 | Internal server error | server_error |
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 tokenWith 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_id": "wego-20260807-000412",
"parent_operation_id": null,
"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": true,
"sim_id": "sim-000001",
"origin": "aggregated"
}| Field | Type | Required | Description |
|---|---|---|---|
request_id | string | yes | Idempotency key. Generated by the Notion Board. One execution per value. |
parent_operation_id | UUID or null | no | Set when this is a manual replay of a prior operation. |
agent_id | UUID | yes | Resolved by n8n from the Notion click context. |
operator | string | yes | Target operator: mtn or orange. Server selects the best available SIM automatically. |
client | object | yes | Recipient details for an operation. |
client.msisdn | string | yes | Destination MSISDN (9-digit Cameroonian local format). |
client.nom | string | yes | Client display name. |
type_op | string | no | deposit, withdrawal, balance, float_deposit, or mini_statement. Defaults to deposit. |
montant | integer | yes | Amount in FCFA (integer). Above 0 |
merchant_code | string or null | no | Required for Orange withdrawal, float_deposit, and mini_statement. |
contact_id | string or null | no | Opaque to Nexus. Stored at creation and returned on read, so n8n can route the evidence onward. Never interpreted or validated. |
wego_verified | boolean | no | Defaults to true. Set false when the recipient name has not been confirmed against WeGo records. See Name verification. |
sim_id | string or null | no | |
origin | string or null | no | aggregated 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. |
| Code | Condition | Body |
|---|---|---|
| 202 | Accepted and queued | { "operation_id": "uuid", "status": "queued" } |
| 401 | Bad API key | { "detail": "..." } |
| 403 | The agent's access profile does not permit the operation | { "detail": "reason" } |
| 409 | request_id already exists | Existing operation object. Treat as success, not an error. Do not create a new id. |
| 422 | Operator mismatch, unknown prefix, SIM frozen, no active SIM, no SIM with a measured balance, stale balance, or insufficient balance | { "detail": "reason" } |
| 423 | Circuit breaker active | { "detail": "circuit_breaker" } |
| 429 | Too 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.
202{}The declared operator must match the destination number. A mismatch is rejected, and so is a prefix belonging to no operator.
| Operator | Prefixes |
|---|---|
| MTN | 670 to 679, 650 to 654, 680, 682, 683 |
| Orange | 690 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.
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.
| Origin | Set by | Meaning |
|---|---|---|
aggregated | X-API-Key with no origin, or origin: "aggregated" | Came from intake through n8n. The default, so n8n keeps working unchanged. |
assisted | Any 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. |
matching | POST /operations/matching only | An 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.
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.
max_amount exists in two places and means different things. Confusing them is easy, so:
| Where | Scope | Enforced | |
|---|---|---|---|
| Agent ceiling | access_profiles.max_amount, set by the access grant | one operation, this agent | yes |
| Fleet limit | limits.max_amount, set by PATCH /admin/limits | one operation, everyone on that operator and type | no |
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.
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_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.
| Detail | Meaning |
|---|---|
No active {operator} SIM available | No active SIM on an active device for that operator |
No active {operator} SIM is assigned to a device | Active SIMs exist for that operator but all are in the unassigned pool. Assign one to a device |
SIM {id} is not assigned to a device | The named SIM is in no handset. Assign it before using it |
No {operator} SIM has a measured balance | SIMs exist but none has been measured. Run a balance check |
SIM {id} has no measured balance | The 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.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
status | query | string or null | Filter by status: queued, running, awaiting_evidence, awaiting_manual_dial, confirmed, failed, no_evidence, rejected |
sim_id | query | string or null | Filter by SIM id |
agent_id | query | string or null | Filter by agent. Admins only. A non-admin is always scoped to itself, and passing another agent returns 403. |
date | query | string or null | One 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_from | query | string or null | Inclusive start of a calendar range, YYYY-MM-DD. Optional, so date_to alone means "up to". Ignored when date is given. |
date_to | query | string or null | Inclusive 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. |
search | query | string or null | Case 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. |
limit | query | integer | Max 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.
200[
{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}
]| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Only an admin can list another agent's operations |
| 422 | Unknown status value |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Get a single operation by UUID.
A non-admin can retrieve only an operation attributed to its token. An admin can retrieve any operation.
Path parameter: operation_id (UUID)
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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:
| Status | retry_allowed | retry_requires_confirmation |
|---|---|---|
failed, rejected | true | false |
no_evidence | true | true |
confirmed, queued, running, awaiting_evidence | false | n/a |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | You cannot access another agent's operation |
| 404 | Operation not found |
| 422 | The id is not a valid UUID |
| 429 | Too many requests from this caller; retry after the Retry-After header |
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.
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".
| Code | Condition |
|---|---|
| 200 | Cancelled |
| 401 | Missing or invalid credentials |
| 403 | You cannot access another agent's operation |
| 404 | Operation not found |
| 409 | Operation is already in a terminal state |
| 422 | Invalid UUID format |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}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.
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.
Record a transfer the agent is about to dial by hand.
Board only. The machine key names an arbitrary agent, so accepting it here would let intake fabricate work attributed to somebody who never dialled.
{
"contact_id": "notion-page-id",
"beneficiary_msisdn": "670000528",
"montant": 25000,
"sim_id": "mtn-01"
}| Field | Type | Required | Description |
|---|---|---|---|
contact_id | string or null | no | Opaque to Nexus. Where the proof is routed once the SMS confirms. |
beneficiary_msisdn | string | yes | Destination MSISDN. The operator is derived from the prefix, so it is not asked for. |
montant | integer | yes | Amount in FCFA (integer). Above 0 |
sim_id | string or null | no | Name 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.
| Code | Condition |
|---|---|
| 201 | Created. Returns the operation detail with status: "awaiting_manual_dial" |
| 401 | Missing or invalid credentials |
| 403 | The agent's access profile does not permit it, or the named SIM is not assigned to them |
| 422 | Unknown prefix, operator mismatch, no eligible SIM, or an unmeasured or insufficient balance |
| 429 | Too many requests from this caller; retry after the Retry-After header |
201{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}List matching operations. A non-admin sees only its own; an admin sees all.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Default 50 |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}
]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.
| Code | Condition |
|---|---|
| 200 | Updated |
| 401 | Missing or invalid credentials |
| 403 | Not the agent's own operation, and not an admin |
| 404 | Matching operation not found |
| 409 | Already matched or terminal, so no longer editable |
| 422 | The id is not a valid UUID |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
{
"contact_id": "contact-000001",
"beneficiary_msisdn": "670000001",
"montant": 25000
}| Field | Type | Required | Description |
|---|---|---|---|
contact_id | string or null | no | |
beneficiary_msisdn | string or null | no | |
montant | integer or null | no | Above 0 |
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}Cancel a matching operation while it is still unmatched. Sets it to rejected and drops the pending row.
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | You cannot access another agent's operation |
| 404 | Matching operation not found |
| 409 | The matching operation has left awaiting_manual_dial and can no longer be changed |
| 422 | The id is not a valid UUID |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}Attach an orphan evidence row to an operation by hand, for when correlation could not.
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.
| Code | Condition |
|---|---|
| 200 | Attached. Returns the confirmed operation detail |
| 401 | Missing or invalid credentials |
| 403 | Not the agent's operation, and not a supervisor or admin |
| 404 | Operation or evidence not found |
| 409 | The evidence already names an operation, or the operation is terminal |
| 422 | The evidence arrived on a different SIM |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
{
"evidence_id": "3f2c0000-0000-4000-8000-000000000001"
}| Field | Type | Required | Description |
|---|---|---|---|
evidence_id | UUID | yes |
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}List transfers that dialled successfully and never got their proof: the arbitration queue.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Default 50 |
days | query | integer or null | |
from | query | string or null | |
to | query | string or null | |
all | query | boolean | Default false |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"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": false
}
]Settle a no_evidence transfer from the board.
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.
{
"outcome": "arrived",
"note": "Customer confirmed on the phone."
}| Field | Type | Required | Description |
|---|---|---|---|
outcome | string | yes | arrived confirms it, did_not_arrive fails it. |
note | string | yes | What 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.
| Code | Condition |
|---|---|
| 200 | Settled. Returns the operation detail |
| 401 | Missing or invalid credentials |
| 403 | Not the agent's transfer and not on a SIM it holds |
| 404 | Operation not found |
| 409 | Not in no_evidence, so it already has its answer |
| 422 | Empty note |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
200{
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | Default 50 |
days | query | integer or null | |
from | query | string or null | |
to | query | string or null | |
all | query | boolean | Default false |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"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>"
}
]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.
| Parameter | In | Type | Description |
|---|---|---|---|
unmatched | query | boolean | Must be true. The route lists orphans only, and refuses otherwise rather than implying it can list everything. |
limit | query | integer | Capped at 250. |
days | query | integer or null | How far back to look, in local calendar days. |
all | query | boolean | Reach 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. |
from | query | string or null | Local calendar day to start from, YYYY-MM-DD. Wins over days. |
to | query | string or null | Local 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.
| Code | Condition |
|---|---|
| 200 | Success |
| 400 | Set unmatched=true to list orphan evidence |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
200[
{
"id": "3f2c0000-0000-4000-8000-000000000001",
"sim_id": "sim-000001",
"source": "sms",
"operator_ref": "<operator_ref>",
"amount": 25000,
"is_ambiguous": false,
"raw_text": "<raw_text>",
"received_at": "2026-09-23T10:15:00+01:00",
"counterparty_msisdn": "670000001",
"counterparty_name": "Awa N.",
"direction": "outgoing",
"kind": "DEP"
}
]Set aside a message no transfer will ever claim, so it leaves the queue without being attached to anything.
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.
| Code | Condition |
|---|---|
| 204 | Dismissed |
| 401 | Missing or invalid credentials |
| 403 | The message arrived on a SIM the agent is not assigned |
| 404 | Evidence not found |
| 409 | This message is already attached to an operation |
| Parameter | In | Type | Description |
|---|---|---|---|
evidence_id | path | UUID |
{
"reason": "balance_reading"
}| Field | Type | Required | Description |
|---|---|---|---|
reason | string | yes | One of balance_reading, operator_refusal, already_settled, not_ours, other |
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.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | 1 to 250. Default 50 |
days | query | integer or null | How far back to look, in local calendar days. |
all | query | boolean | Reach every movement, past the days ceiling. |
from | query | string or null | Local calendar day to start from, YYYY-MM-DD. Wins over days. |
to | query | string or null | Local calendar day to end on, inclusive of that whole day. |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
200[
{
"id": "3f2c0000-0000-4000-8000-000000000001",
"sim_id": "sim-000001",
"source": "sms",
"operator_ref": "<operator_ref>",
"amount": 25000,
"is_ambiguous": false,
"raw_text": "<raw_text>",
"received_at": "2026-09-23T10:15:00+01:00",
"counterparty_msisdn": "670000001",
"counterparty_name": "Awa N.",
"direction": "outgoing",
"kind": "DEP"
}
]Every stored message carries a kind, decided once from its text in app/sms/kind.py. The board shows the code as written.
| Kind | Message | Treatment |
|---|---|---|
DEP | A deposit to a customer | Can confirm a transfer |
BAL | A balance reply | Can confirm a balance check only |
UV_OUT, UV_IN | A UV transfer sent or received | Movement |
FLOAT_OUT, FLOAT_IN | A float transfer sent or received | Movement |
UV_WITH, FLOAT_WITH | A withdrawal | Movement |
AIRTIME | An airtime sale | Movement |
COM_SWEEP | Commission moved to the main account | Movement |
REV | An earlier transfer returned to our SIM | Movement |
MTN_REC, ORANGE_REC | A mini statement | Stored, never matched |
ADMIN, COM, MKT, TPL | An account notice, commission notice, marketing, or an unfilled template | Stored, never matched |
FAIL | An operator refusal | Stored, 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.
Routes called by the Nexus Android app. Auth is a device-scoped bearer token.
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.
200 (operation available){
"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": true,
"dest_name": "Awa N.",
"msisdn_dest": "670000001",
"operator": "mtn"
}| Field | Type | Description |
|---|---|---|
ussd_steps | array of strings | Ordered 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_string | string | Legacy single-string form, retained for the operation record. The device executes ussd_steps. |
op_type | string | Operation type. The device uses this to decide whether a name check applies. |
wego_verified | boolean | When false on a deposit, the device pauses at the confirmation screen and compares the operator name against dest_name. |
dest_name | string or null | Expected 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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| 500 | The USSD steps could not be built for this operation |
Report the outcome of a USSD execution.
Authorization: Bearer <device_token>{
"operation_id": "uuid",
"outcome": "ussd_ok",
"ussd_response": "Transaction en cours de traitement...",
"executed_at": "2026-08-07T14:22:12+01:00",
"failure_detail": null
}| Field | Type | Required | Description |
|---|---|---|---|
operation_id | UUID | yes | Operation being reported. |
outcome | string | yes | See the table below. |
ussd_response | string or null | no | Verbatim operator text from the final dialog. Stored as ussd_response. |
executed_at | datetime | yes | When the device executed the operation. |
failure_detail | string or null | no | Explanation 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:
| Value | Effect | Terminal |
|---|---|---|
ussd_ok | Move to awaiting_evidence, release the device, and wait for SMS within the evidence window | No |
ussd_unconfirmed | The PIN was sent and the operator showed no closing screen. Move to awaiting_evidence like ussd_ok, so the SMS decides | No |
ussd_failed | Move to failed | Yes |
ussd_timeout | Move to no_evidence | Yes |
no_service | Move to no_evidence | Yes |
sim_absent | Move to failed, trigger alert | Yes |
name_mismatch | Move 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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Operation not found |
| 409 | Operation result has already been recorded |
| 422 | The id is not a valid UUID |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"operation_id": "3f2c0000-0000-4000-8000-000000000001",
"status": "confirmed",
"evidence_expires_at": "2026-09-23T10:15:00+01:00"
}Submit operator SMS messages for correlation. The app forwards all messages from known operator senders; the server handles matching.
Authorization: Bearer <device_token>{
"sim_id": "mtn-01",
"subscription_slot": 1,
"correlate": true,
"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 field | Required | Meaning |
|---|---|---|
sender | yes | The operator address the message came from. |
raw | yes | The message text, unmodified. |
received_at | yes | The operator's timestamp, as the handset reports it. |
device_received_at | no | When 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_id | no | Identity 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:
- Deduplicate on
sms_hash, which carriesclient_message_idwhen 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. - Parse with the active operator parser: extract
montant,frais,balance_after,operator_ref. - Correlate to an active operation (
queued,running,awaiting_evidence, orawaiting_manual_dial) on the same SIM by exact amount. Ano_evidenceoperation is also eligible while itsfinished_atis withinlate_evidence_minutes; active candidates are tried first. - Coherent match: move to
confirmed, updatebalance_mirrorfrombalance_after. - Divergent amount or MSISDN: no confirmation, trigger alert.
- 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. - No match: store as orphan with
operation_id = NULL. Never deleted.
200| Field | Type | Required | Description |
|---|---|---|---|
sim_id | string | yes | |
subscription_slot | integer or null | no | |
correlate | boolean | no | Default true |
messages | array of object | yes |
{
"matched": true,
"restated": true
}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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | SIM is not assigned to this device |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"matched": false,
"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": false
},
"retry_allowed": false,
"retry_requires_confirmation": false,
"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": false,
"balance": 1,
"previous_balance": 1
}Signal device liveness. Must be called every 60 seconds.
Authorization: Bearer <device_token>{
"app_version": "1.0.3",
"battery": 87,
"network": "4G",
"sims": [
{
"sim_id": "mtn-01",
"present": true,
"signal": 3
}
],
"android_version": "<android_version>",
"android_sdk": 1,
"manufacturer": "<manufacturer>",
"model": "<model>",
"battery_exempt": false,
"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.
| Group | Fields |
|---|---|
app | version, ui (open, background or closed), uptime_seconds |
android | version, sdk, manufacturer, model, oem_family |
battery | level (0 to 100), charging |
network | type, connected, internet_reachable |
permissions | call_phone, read_phone_state, read_phone_numbers, receive_sms, read_sms, notifications |
accessibility | enabled, running |
background | battery_exempt, service_running, foreground, service_type, wake_lock_held, time_limit_stops, last_time_limit_stop_at |
polling | wanted, last_tick_at, last_poll_at, consecutive_failures, backoff_until, stuck_ticks, dialling, last_error |
sms | outbox_pending, outbox_oldest_at, spool_pending, spool_oldest_at, spool_insert_failures, spool_last_failure |
sims | Up to 4 of slot, operator, sim_id, present |
recent_errors | Up to 5 of at, message |
| Field | Type | Required | Description |
|---|---|---|---|
app_version | string | yes | |
battery | integer | yes | 0 to 100 |
network | string | yes | |
sims | array of object | yes | |
android_version | string or null | no | Up to 40 characters |
android_sdk | integer or null | no | |
manufacturer | string or null | no | Up to 80 characters |
model | string or null | no | Up to 80 characters |
battery_exempt | boolean or null | no | |
report | object or null | no |
| Code | Condition |
|---|---|
| 204 | Done, no body |
| 401 | Missing or invalid credentials |
| 429 | Too many requests from this caller; retry after the Retry-After header |
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.
Authorization: Bearer <device_token>| Parameter | In | Type | Description |
|---|---|---|---|
since | query | datetime or null | Only operations that changed at or after this time. |
limit | query | integer | 1 to 250. Default 100 |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 422 | Invalid since or limit |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"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"
}
]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.
Authorization: Bearer <device_token>| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"sim_id": "sim-000001",
"slot": 1,
"operator": "mtn",
"msisdn": "670000001",
"status": "confirmed",
"balance": 1,
"balance_updated_at": "2026-09-23T10:15:00+01:00"
}
]The calling device's id, fleet label and status, for the name the phone shows.
Authorization: Bearer <device_token>| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Device not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"device_id": "device-000001",
"label": "<label>",
"status": "confirmed"
}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:
| Operator | Confirmation text |
|---|---|
| MTN | Confirmez le depot de 500 FCFA Ã ADELINE BIKOA (237683000520).Entrez votre code PIN: |
| Orange | Depot 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.
Routes for operational management. All require a board session token belonging to an agent with the admin role:
Authorization: Bearer <session_token>A non-admin agent receives 403. The machine API key is not accepted on these routes.
Prevent new operations on a SIM. In-flight operations continue.
Response 200: { "status": "frozen" }
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
200{}Re-enable a frozen SIM.
Response 200: { "status": "active" }
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
200{}Snapshot of all SIMs with status, balance mirror, and last heartbeat.
200[
{
"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"
}
]| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
Freeze the entire fleet immediately. All POST /operations return 423 until lifted.
{ "active": true }Pass "active": false to lift the breaker.
Response 200: { "status": "active" } or { "status": "inactive" }
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
200{}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.
{
"decision": "<decision>",
"note": "<note>"
}resolution must be "confirmed" or "failed".
Response 200: Updated operation object.
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | string |
| Field | Type | Required | Description |
|---|---|---|---|
decision | string | yes | Arbitration decision: confirmed or failed |
note | string | yes | Reason for decision. Up to 500 characters |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Operation not found |
| 422 | The id is not a valid UUID |
200{}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.
{
"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.
| Field | Type | Required | Description |
|---|---|---|---|
operator | string or null | no | Operator code (mtn, orange) or null for global limit |
op_type | string | no | Operation 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_amount | integer | yes | Maximum single transaction amount in FCFA. Above 0 |
max_daily_agent | integer | yes | Maximum daily total per agent in FCFA. Above 0 |
max_daily_sim | integer | yes | Maximum daily total per SIM in FCFA. Above 0 |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 422 | Unknown operator code |
200{}Return every active limit.
200{
"enforced": false,
"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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
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.
{
"allowed_sim_ids": [
"sim-abc123def456"
],
"allowed_op_types": [
"deposit"
],
"max_amount": 500000
}| Field | Type | Required | Description |
|---|---|---|---|
allowed_sim_ids | array of string | yes | SIM ids this agent may use. Must name at least one. |
allowed_op_types | array of string | no | Operation 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_amount | integer | no | Ceiling 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.
| Parameter | In | Type | Description |
|---|---|---|---|
agent_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Agent not found |
| 422 | One or more SIM ids do not exist |
200{
"agent_id": "agent-000001",
"login": "<login>",
"role": "agent",
"is_active": false,
"allowed_sim_ids": [
"<allowed_sim_ids>"
],
"allowed_op_types": [
"<allowed_op_types>"
],
"max_amount": 25000
}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.
200{
"agent_id": "uuid",
"login": "agent-01",
"role": "agent",
"is_active": true,
"allowed_sim_ids": [
"sim-abc123def456"
],
"allowed_op_types": [
"deposit"
],
"max_amount": 500000
}| Parameter | In | Type | Description |
|---|---|---|---|
agent_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Agent not found |
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.
{
"confirm": "RESET NEXUS PRODUCTION DATA"
}The phrase is part of the request schema. Any other value returns 422 before a single row is touched.
200{
"cleaned": true,
"deleted": {},
"preserved": {},
"queue_purged": true,
"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.
| Field | Type | Required | Description |
|---|---|---|---|
confirm | string | yes | Must be exactly 'RESET NEXUS PRODUCTION DATA'. |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
Lift the circuit breaker: restore all frozen devices to active.
Sets all devices with status=FROZEN to status=ACTIVE. Creates audit log entry.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
200{}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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
200{}Authenticate an agent and receive a session token.
{
"login": "test-agent",
"password": "test-password-123"
}200{
"token": "session-token-string",
"agent_id": "uuid",
"role": "agent",
"expires_at": "2026-08-07T19:22:00+01:00",
"must_change_password": false
}Token is valid for 5 hours from issue time. It is not extended by subsequent requests.
| Field | Type | Required | Description |
|---|---|---|---|
login | string | yes | Agent login username. Up to 100 characters |
password | string | yes | Agent password. Up to 100 characters |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Revoke the current session.
Response 204: No body.
| Code | Condition |
|---|---|
| 204 | Done, no body |
| 401 | Missing or invalid credentials |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Return the authenticated agent's profile.
200{
"agent_id": "uuid",
"login": "test-agent",
"full_name": "Test Agent",
"role": "agent",
"is_active": true,
"created_at": "2026-08-01T10:00:00+01:00"
}| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Read the calling agent's own limits: the SIMs it may send from, the operation types it may run, and its per operation ceiling.
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.
200{
"agent_id": "uuid",
"login": "amina.b",
"role": "agent",
"is_active": true,
"allowed_sim_ids": [
"uuid"
],
"allowed_op_types": [
"deposit"
],
"max_amount": 500000
}| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Return the calling agent's assigned SIM summaries and their measured balances.
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.
200[
{
"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
}
]| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Change the current agent's password. Revokes all active sessions on success.
{
"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.
| Field | Type | Required | Description |
|---|---|---|---|
current_password | string | yes | |
new_password | string | yes | Up to 100 characters |
| Code | Condition |
|---|---|
| 204 | Done, no body |
| 401 | Missing or invalid credentials |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
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_TOKENTwo 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.
| Status | Meaning |
|---|---|
201 | Admin created |
403 | Token absent, wrong, or BOOTSTRAP_TOKEN unset |
409 | An 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.
{
"login": "<login>",
"full_name": "Awa N.",
"password": "<password>",
"role": "agent"
}| Field | Type | Required | Description |
|---|---|---|---|
login | string | yes | Up to 100 characters |
full_name | string | yes | Up to 200 characters |
password | string | yes | Up to 100 characters |
role | string | no | Default "agent" |
| Code | Condition |
|---|---|
| 201 | Created |
| 403 | Bootstrap is not available |
| 409 | An admin already exists. Create further agents with POST /v1/agents/admin |
| 429 | Too many requests from this caller; retry after the Retry-After header |
201{
"agent_id": "agent-000001",
"login": "<login>",
"full_name": "Awa N.",
"role": "agent",
"is_active": false,
"created_at": "2026-09-23T10:15:00+01:00"
}Create a new agent account. Admin only. Set "role": "admin" to create another admin.
{
"login": "new-agent",
"full_name": "New Agent",
"password": "initial-password",
"role": "agent"
}role values: "agent", "supervisor", "admin"
Response 201: Agent profile object.
| Field | Type | Required | Description |
|---|---|---|---|
login | string | yes | Up to 100 characters |
full_name | string | yes | Up to 200 characters |
password | string | yes | Up to 100 characters |
role | string | no | Default "agent" |
| Code | Condition |
|---|---|
| 201 | Created |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 409 | An agent with this login already exists |
| 429 | Too many requests from this caller; retry after the Retry-After header |
201{
"agent_id": "agent-000001",
"login": "<login>",
"full_name": "Awa N.",
"role": "agent",
"is_active": false,
"created_at": "2026-09-23T10:15:00+01:00"
}Replace an agent's password with a generated temporary one. Admin only.
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.
200{
"agent_id": "uuid",
"login": "amina.b",
"temporary_password": "pebble-flint-jasper-1337"
}| Status | Meaning |
|---|---|
200 | Temporary password issued |
403 | Caller is not an admin |
404 | No such agent |
| Parameter | In | Type | Description |
|---|---|---|---|
agent_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
List all agent accounts.
Response 200: Array of agent profile objects.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"agent_id": "agent-000001",
"login": "<login>",
"full_name": "Awa N.",
"role": "agent",
"is_active": false,
"created_at": "2026-09-23T10:15:00+01:00"
}
]Update an agent's name, role, or active status.
{
"full_name": "Updated Name",
"is_active": true,
"role": "supervisor"
}Response 200: Updated agent profile object.
| Parameter | In | Type | Description |
|---|---|---|---|
agent_id | path | string |
| Field | Type | Required | Description |
|---|---|---|---|
full_name | string or null | no | Up to 200 characters |
is_active | boolean or null | no | |
role | string or null | no |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"agent_id": "agent-000001",
"login": "<login>",
"full_name": "Awa N.",
"role": "agent",
"is_active": false,
"created_at": "2026-09-23T10:15:00+01:00"
}Deactivate an agent and revoke all active sessions. The account is not deleted.
Response 204: No body. Idempotent.
| Parameter | In | Type | Description |
|---|---|---|---|
agent_id | path | string |
| Code | Condition |
|---|---|
| 204 | Done, no body |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Agent not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Routes for provisioning Android devices. Every route requires a Board session token belonging to an admin.
Register a new Android device and receive a device token.
{
"label": "Phone-MTN-01"
}201{
"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.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Up to 200 characters |
| Code | Condition |
|---|---|
| 201 | Created |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 429 | Too many requests from this caller; retry after the Retry-After header |
List all devices with their SIMs and last heartbeat.
200[
{
"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"
}
}
]| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 429 | Too many requests from this caller; retry after the Retry-After header |
Return detail for a single device, including its linked SIMs.
Response 200: Device object with sims array. Each SIM includes subscription_slot, where 0 is SIM 1 and 1 is SIM 2.
| Code | Condition |
|---|---|
| 200 | Device found |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Device not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
device_id | path | string |
200{
"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
}
]
}Update a device's label.
{
"label": "New Label"
}Response 200: Updated device object.
| Parameter | In | Type | Description |
|---|---|---|---|
device_id | path | string |
| Field | Type | Required | Description |
|---|---|---|---|
label | string or null | no | Up to 200 characters |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Device not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"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 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.
Response 204: No body.
| Parameter | In | Type | Description |
|---|---|---|---|
device_id | path | string |
| Code | Condition |
|---|---|
| 204 | Done, no body |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Device not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
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.
| Parameter | In | Type | Description |
|---|---|---|---|
device_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | Device not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200{
"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": false,
"issues": [
{
"code": "accessibility_off",
"severity": "critical",
"message": "Turn on the accessibility service for Nexus Agent."
}
],
"report": {}
}SIM inventory and provisioning routes require a Board session token belonging to an admin. PATCH /sims/{sim_id}/balance remains machine-key authenticated.
List registered SIMs.
| Parameter | In | Type | Description |
|---|---|---|---|
unassigned | query | boolean or null | true 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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
200[
{
"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"
}
]Return detail for a single SIM.
Response 200: SIM object.
| Code | Condition |
|---|---|
| 200 | SIM found |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
200{
"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"
}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.
{
"operator": "mtn",
"msisdn": "670000001",
"pin": "1234",
"device_id": "a1b2c3d4e5f6a7b8",
"subscription_slot": 0
}| Field | Type | Required | Description |
|---|---|---|---|
operator | string | yes | "mtn" or "orange". |
msisdn | string | yes | SIM phone number (local format). Must be unique. |
pin | string | yes | SIM PIN (4-8 digits). Encrypted before storage; plaintext never persisted. |
device_id | string or null | no | ID returned by POST /devices. Omit, together with subscription_slot, to leave the card unassigned. |
subscription_slot | string or null | no | Physical 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. |
- A device may have at most 2 SIMs (one per slot). Returns
409if the device already has 2 SIMs or if the chosen slot is already occupied. - Supplying one of
device_idandsubscription_slotwithout the other returns422. A slot is meaningless without the device that owns it.
201{
"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).
| Status | Meaning |
|---|---|
| 201 | SIM registered |
| 404 | device_id was given but no such device exists |
| 409 | MSISDN already exists, device already has 2 SIMs, slot already occupied, or the device is not active |
| 422 | device_id and subscription_slot were not supplied together |
| Code | Condition |
|---|---|
| 201 | Created |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | device_id was given but no such device exists |
| 409 | MSISDN already exists, device already has 2 SIMs, slot already occupied, or the device is not active |
| 422 | device_id and subscription_slot were not supplied together |
Update SIM configuration or alert threshold.
{
"status": "frozen",
"low_balance_threshold": 50000,
"high_balance_threshold": 500000
}| Field | Type | Required | Description |
|---|---|---|---|
status | string or null | no | "active", "frozen", or "retired". |
low_balance_threshold | integer or null | no | Alert when balance falls below this value (FCFA). |
high_balance_threshold | integer or null | no | Optional upper alert threshold (FCFA). |
Response 200: Updated SIM object.
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
200{
"sim_id": "sim-000001",
"operator": "mtn",
"msisdn": "670000001",
"device_id": "device-000001",
"created_at": "2026-09-23T10:15:00+01:00"
}Permanently delete a SIM. Operations referencing this SIM are preserved with sim_id nulled. Evidences and daily reconciliations are deleted with the SIM.
Response 204: No body.
| Code | Condition |
|---|---|
| 204 | Deleted |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM 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.
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
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.
{
"device_id": "a1b2c3d4e5f6a7b8",
"subscription_slot": 0
}| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | yes | Target device. |
subscription_slot | string | yes | 0 for SIM 1, 1 for SIM 2. |
200{
"sim_id": "sim-abc123def456",
"device_id": "a1b2c3d4e5f6a7b8",
"subscription_slot": 0
}| Code | Condition |
|---|---|
| 200 | Assigned or moved |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM or device not found |
| 409 | Device 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.
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
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.
200{
"sim_id": "sim-abc123def456",
"device_id": null,
"subscription_slot": null
}| Code | Condition |
|---|---|
| 200 | Unassigned |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
| 409 | The 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.
| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
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.
{
"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.
200{
"sim_id": "sim-abc123def456",
"balance_mirror": 812300,
"balance_updated_at": "2026-08-25T14:22:00Z",
"balance_source": "n8n"
}| Code | Condition |
|---|---|
| 200 | Recorded |
| 401 | Missing or invalid credentials |
| 404 | SIM not found |
| 422 | Negative balance, or observed_at in the future |
Writes an audit_log row carrying the previous and new values.
X-API-Key| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
| Field | Type | Required | Description |
|---|---|---|---|
balance | integer | yes | Measured balance in FCFA. At least 0 |
observed_at | datetime or null | no | |
source | string or null | no | Who recorded the reading. Defaults to api.. One of sms, balance_check, api, n8n |
Return operation history and volume stats for a SIM.
200{
"sim_id": "mtn-01",
"total_operations": 142,
"total_confirmed": 139,
"total_failed": 2,
"total_no_evidence": 1,
"balance_mirror": 812300,
"operations": [...]
}| Parameter | In | Type | Description |
|---|---|---|---|
sim_id | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 404 | SIM not found |
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.
| State | Meaning |
|---|---|
pending | Received from intake, nobody has launched it |
launched | An agent launched it, the operation exists |
done | The launched operation reached a terminal state |
cancelled | Set 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.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
state | query | string or null | Filter by state: pending, launched, done, cancelled. An unknown value returns 422 |
limit | query | integer | Max results. Default 100, max 250 |
Response 200: Array of transaction summaries, newest first.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | Unknown state value |
200[
{
"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": false,
"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"
}
]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.
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.
| Code | Condition |
|---|---|
| 200 | Found |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Not found, or not visible to this caller |
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
200{
"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": false,
"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"
}Correct the receiver before launching.
{
"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.
| Code | Condition |
|---|---|
| 200 | Corrected |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Not found |
| 409 | The transaction is not pending. Its operation already carries the details |
| 422 | Neither field was supplied |
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
| Field | Type | Required | Description |
|---|---|---|---|
receiver_name | string or null | no | Up to 200 characters |
receiver_phone | string or null | no | Up to 20 characters |
contact_id | string or null | no | Up to 200 characters |
200{
"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": false,
"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"
}Create the operation this transaction describes.
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.
200{
"transaction_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"operation_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"state": "launched"
}| Code | Condition |
|---|---|
| 200 | Launched, the operation exists |
| 401 | Missing or invalid credentials |
| 403 | The caller's role or access does not allow this |
| 404 | Not found |
| 409 | Already launched or cancelled. Another agent may have launched it |
| 422 | No SIM available, no measured balance, or the selected SIM cannot cover the amount |
| 423 | The 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.
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
Set a transaction aside without launching it.
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.
{
"reason": "Wrong amount, intake reissuing"
}| Code | Condition |
|---|---|
| 200 | Cancelled |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Not found |
| 409 | The transaction is not pending. A launched operation is cancelled on the operation, not here |
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | yes | Up to 500 characters |
200{
"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": false,
"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"
}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.
X-API-Key{
"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": false,
"transaction_date": "2026-09-23T10:15:00+01:00"
}| Field | Type | Required | Description |
|---|---|---|---|
request_id | string | yes | Up to 200 characters |
parent_request_id | string or null | no | Up to 200 characters |
master_transaction_id | string or null | no | Up to 200 characters |
contact_id | string | yes | Up to 200 characters |
customer_name | string or null | no | Up to 200 characters |
customer_phone | string or null | no | Up to 20 characters |
receiver_name | string | yes | Up to 200 characters |
receiver_phone | string | yes | Up to 20 characters |
operator | string | yes | One of mtn, orange |
received_amount_xaf | integer | yes | Above 0 |
rate | number or null | no | Above 0 |
send_amount_eur | number or null | no | Above 0 |
wego_verify | boolean | no | Default false |
transaction_date | datetime | yes |
| Code | Condition |
|---|---|
| 201 | Created |
| 401 | Missing or invalid credentials |
| 409 | A transaction with this id already exists |
| 422 | The receiver phone is not a Cameroon mobile money number |
201{
"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": false,
"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"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Transaction not found |
| 409 | The transaction is not pending. Payment can only be confirmed before launching |
200{
"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": false,
"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"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Transaction not found |
| 409 | The transaction is not pending. The transfer has already been sent |
200{
"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": false,
"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"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
transaction_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Transaction not found |
| 409 | The transaction is not pending. Only a transaction nobody has launched can be closed by a receipt |
200{
"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": false,
"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"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
days | query | integer or null | Local calendar days back from this midnight. Default 7, min 1, max 365. days=1 is today |
from | query | string or null | YYYY-MM-DD, inclusive. Wins over days |
to | query | string or null | YYYY-MM-DD, inclusive. The window ends at the midnight after it, so a whole day is covered |
all | query | boolean | Reach 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.
200| Field | Type | Description |
|---|---|---|
fleet | boolean | Whether the figures cover everyone or only the caller |
since | datetime, null | Window start. Null when unbounded |
until | datetime, null | Window end. Null when unbounded |
money | object | Value moved, and what it cost |
activity | object | How much work ran, and when |
reliability | object | Whether the system confirmed on its own, and how fast |
load | object | What is waiting on a person right now |
money| Field | Type | Description |
|---|---|---|
sent_xaf | integer | Confirmed 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 |
transfers | integer | Confirmed transfers behind that value |
median_transfer | integer | Median confirmed amount |
largest_transfer | integer | Largest single confirmed amount |
eur_taken | number | EUR received from intake |
received_xaf | integer | XAF against that EUR |
realised_rate | number, null | XAF per EUR as it happened rather than as configured. Fleet only |
fees_xaf | integer | Operator fees read from confirming messages |
fees_known_for | integer | Confirmed 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_xaf | integer | Value waiting on a human decision |
at_risk_count | integer | Transfers behind it |
lost_xaf | integer | Refused by the operator, or settled as never arrived |
lost_count | integer | Transfers behind it |
activity| Field | Type | Description |
|---|---|---|
operations | integer | Every operation, balance checks included |
transfers | integer | Money movements only |
balance_checks | integer | Measurements only |
active_sims | integer | SIMs that ran something |
active_agents | integer | Agents who ran something |
daily | array | One entry per calendar day: day, operations, transfers, balance_checks, value_xaf, confirmed, auto_confirmed, failed, unproven |
by_weekday | array | weekday as Monday 1 through Sunday 7, and operations |
by_hour | array | weekday, hour in local time, and operations |
by_agent | array | agent_id, name, operations, value_xaf. Empty for an agent |
reliability| Field | Type | Description |
|---|---|---|
confirmed | integer | Operations that reached confirmed |
auto_confirmed | integer | Confirmed with nobody arbitrating or attaching. A manual attachment records who did it, so it is not counted here |
auto_confirm_rate | number, null | auto_confirmed over confirmed. Null when nothing confirmed |
settled_by_hand | integer | Somebody decided the outcome |
failed | integer | The operator refused |
unproven | integer | No proof arrived inside the window |
proof_delay_median_s | number, null | Seconds from a message reaching Nexus to it being paired. This is processing time, not the customer's wait |
proof_delay_p90_s | number, null | The slowest tenth of the same measure |
total_messages | integer | Messages received. Scoped through the operation, so an agent counts its own |
ambiguous_messages | integer | Messages that could match more than one transfer |
collision_rate | number, null | Ambiguous over total. Null when no messages arrived |
thresholds | object | evidence_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_status | object | Every 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.
| Field | Type | Description |
|---|---|---|
unproven_waiting | integer | Transfers awaiting a decision |
messages_waiting | integer | Messages awaiting a pairing |
oldest_unproven_hours | number, null | Age of the oldest waiting transfer |
oldest_message_hours | number, null | Age of the oldest unpaired message |
dismissed | integer | Messages set aside in the window |
settled | integer | Transfers decided in the window |
sims_unmeasured | integer | No measured balance, so they run nothing but a balance check |
sims_stale | integer | Measured, but not recently |
sims_frozen | integer | Frozen |
sims_unassigned | integer | On no device |
| Status | Meaning |
|---|---|
| 401 | No board session token |
| 422 | A reversed range, or a range starting in the future |
{
"fleet": false,
"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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | No board session token |
| 403 | The agent is inactive or no longer exists |
| 422 | A reversed range, or a range starting in the future |
200{
"fleet": false,
"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
}
}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.
Query parameters: days, from and to, exactly as GET /analytics reads them.
200| Field | Type | Description |
|---|---|---|
since, until | string | The window read |
finished | integer | Operations that reached confirmed or no_evidence inside it |
confirmed | integer | Of those, the ones whose message arrived |
no_evidence | integer | Of those, the ones whose message never did |
loss_rate | number | no_evidence over finished, 0 to 1 |
devices | array | One 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.
| Parameter | In | Type | Description |
|---|---|---|---|
days | query | integer or null | 1 to 365. Default 7 |
from | query | string or null | |
to | query | string or null |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Admin role required |
| 422 | The from day is after the to day |
200{
"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": false
}
]
}Render one confirmed transfer as a receipt the customer can be shown.
X-API-KeyThe 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.
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| Header | Required | Value |
|---|---|---|
X-API-Key | yes | The machine key. No board session token is accepted. |
No request body. Every option is a query parameter.
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | UUID | |
format | query | string | png, pdf. The PDF is one page sized to the receipt itself, not a receipt on a sheet of A4. |
theme | query | string | light, dark. |
lang | query | string | en, 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.
| Header | Value |
|---|---|
Content-Type | image/png or application/pdf, matching format |
Content-Disposition | inline; 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-Control | no-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.
| Code | Condition | Body |
|---|---|---|
| 200 | The receipt | Image or PDF bytes |
| 401 | No API key | {"detail": "..."} |
| 404 | No such operation | {"detail": "Operation {id} not found."} |
| 409 | The operation cannot support a receipt | {"detail": {"reason": "...", "message": "..."}} |
| 422 | An unknown format, theme or lang | FastAPI validation error |
| 429 | Too many requests from this caller; retry after the Retry-After header | |
| 503 | The renderer is unavailable, the transfer is unaffected | {"detail": {"reason": "renderer_unavailable", "message": "..."}} |
Render a receipt, supplying detail the operation does not hold. Same query parameters and same response as the GET.
X-API-KeyOnly 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.
{
"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
}| Field | Type | Required | Description |
|---|---|---|---|
recipient_name | string or null | no | The beneficiary name, when the operation carries none |
sender_name | string or null | no | The sender, absent outside intake |
sender_msisdn | string or null | no | The sender's number |
sent_amount_eur | number or null | no | The amount the customer paid, greater than zero |
rate_xaf_per_eur | number or null | no | The 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.
{
"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.
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | UUID | |
format | query | string | One of png, pdf. Default "png" |
theme | query | string | One of light, dark. Default "light" |
lang | query | string | One of en, fr. Default "en" |
{
"recipient_name": "Awa N.",
"sender_name": "Awa N.",
"sender_msisdn": "670000001",
"sent_amount_eur": 1.5,
"rate_xaf_per_eur": 655.957
}| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Operation not found |
| 409 | Conflicts with the current state |
| 422 | The request failed validation |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| 503 | A dependency this route needs is unavailable |
Record where a rendered receipt was stored.
X-API-KeyThe 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.
{
"url": "https://r2.example.com/receipts/abc.png",
"format": "png",
"language": "fr",
"theme": "light",
"published_by": "n8n"
}| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Where the file lives, up to 2048 characters |
format | string | no | png or pdf, defaulting to png |
language | string | no | en or fr, defaulting to en |
theme | string | no | light or dark, defaulting to light |
published_by | string or null | no | Whoever 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.
| Code | Condition |
|---|---|
| 201 | Recorded. Returns the row joined to its transfer |
| 401 | No API key |
| 404 | No such operation |
| 422 | An unknown field, or a format, language or theme outside the allowed values |
| 429 | Too many requests from this caller; retry after the Retry-After header |
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | UUID |
201{
"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"
}Every stored rendering of one transfer, newest first.
X-API-KeyEach 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.
[
{
"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.
| Parameter | In | Type | Description |
|---|---|---|---|
operation_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Operation not found |
| 429 | Too many requests from this caller; retry after the Retry-After header |
200[
{
"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"
}
]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.
| Parameter | In | Type | Description |
|---|---|---|---|
format | query | string | One of png, pdf. Default "png" |
theme | query | string | One of light, dark. Default "light" |
lang | query | string | One of en, fr. Default "en" |
{
"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"
}| Field | Type | Required | Description |
|---|---|---|---|
recipient_name | string | yes | Up to 120 characters |
recipient_msisdn | string | yes | Up to 32 characters |
amount_xaf | integer | yes | Above 0 |
operator | string | yes | One of mtn, orange |
confirmed_at | datetime | yes | |
fee_xaf | integer or null | no | At least 0 |
sender_name | string or null | no | Up to 120 characters |
sender_msisdn | string or null | no | Up to 32 characters |
sent_amount_eur | number or null | no | Above 0 |
rate_xaf_per_eur | number or null | no | Above 0 |
source_kind | string | no | One of operation, transaction, standalone. Default "standalone" |
source_id | UUID or null | no |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The request failed validation |
| 503 | A dependency this route needs is unavailable |
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.
{
"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>"
}| Field | Type | Required | Description |
|---|---|---|---|
recipient_name | string | yes | Up to 120 characters |
recipient_msisdn | string | yes | Up to 32 characters |
amount_xaf | integer | yes | Above 0 |
operator | string | yes | One of mtn, orange |
confirmed_at | datetime | yes | |
fee_xaf | integer or null | no | At least 0 |
sender_name | string or null | no | Up to 120 characters |
sender_msisdn | string or null | no | Up to 32 characters |
sent_amount_eur | number or null | no | Above 0 |
rate_xaf_per_eur | number or null | no | Above 0 |
source_kind | string | no | One of operation, transaction, standalone. Default "standalone" |
source_id | UUID or null | no | |
customer_ref | string | yes | Up to 120 characters |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The request failed validation |
200{
"std_code": "STD-1-T-3729H6PVW8-R7",
"customer_ref": "<customer_ref>",
"dispatched": false,
"receipt_png_base64": "<receipt_png_base64>"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
code | path | string |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | No transfer matches this code |
200{
"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"
}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.
X-API-Key{
"std_code": "STD-1-T-3729H6PVW8-R7",
"delivered": false,
"reason": "<reason>",
"provider_message_id": "provider_message-000001",
"occurred_at": "2026-09-23T10:15:00+01:00"
}| Field | Type | Required | Description |
|---|---|---|---|
std_code | string | yes | Up to 40 characters |
delivered | boolean | yes | |
reason | string or null | no | Up to 500 characters |
provider_message_id | string or null | no | Up to 200 characters |
occurred_at | datetime or null | no |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | No receipt was issued with this code |
200{
"std_code": "STD-1-T-3729H6PVW8-R7",
"delivered": false,
"recorded_at": "2026-09-23T10:15:00+01:00"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | integer | 1 to 250. Default 50 |
days | query | integer or null | |
from | query | string or null | |
to | query | string or null | |
all | query | boolean | Default false |
kinds | query | string or null | Comma separated: operation, transaction, standalone. |
sent_only | query | boolean | Only receipts somebody chose to send, not every render. |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
200[
{
"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": false,
"delivered": false,
"delivery_reason": "<delivery_reason>",
"delivered_at": "2026-09-23T10:15:00+01:00"
}
]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.
| Parameter | In | Type | Description |
|---|---|---|---|
kind | path | string | One of operation, transaction |
source_id | path | UUID |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
200{
"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": false,
"delivered": false,
"delivery_reason": "<delivery_reason>",
"delivered_at": "2026-09-23T10:15:00+01:00"
}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.
{
"detail": {
"reason": "no_operator_reference",
"message": "The operator message carried no reference, so there is nothing for the customer to quote."
}
}reason | Meaning | What to check |
|---|---|---|
not_confirmed | The operation is not in confirmed. The message names the state it is actually in | Wait, or settle it through arbitration. A receipt before confirmation tells the customer something Nexus does not know |
no_evidence | Confirmed with no operator message attached | An operation confirmed by hand through resolve or arbitrate has no message, so no reference exists |
no_operator_reference | A message is attached but carries no operator_ref | A parser gap. The transfer is fine, the reference was not extracted |
no_recipient | The operation carries no destination number | Should 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.
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.
| Field | aggregated | assisted | matching |
|---|---|---|---|
| Recipient name and number | yes | yes | yes |
| Amount in XAF | yes | yes | yes |
| Operator reference | yes | yes | yes |
| Sender name | yes | no | no |
| Amount sent in EUR | yes | no | no |
| Exchange rate | yes | no | no |
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 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.
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_id": "n8n-20260818-001",
"dest_msisdn": "670000528",
"dest_name": "Awa N.",
"amount": 25000,
"type_op": "deposit",
"agent_id": "3f2c0000-0000-4000-8000-000000000001"
}| Field | Type | Required | Description |
|---|---|---|---|
request_id | string | yes | Idempotency key. |
dest_msisdn | string | yes | Destination MSISDN. Operator is detected automatically from the Cameroon numbering plan. |
dest_name | string | no | Destination account holder name. |
amount | integer | yes | Amount in XAF. |
type_op | string | no | Defaults to deposit. |
agent_id | UUID | no | Originating agent. |
| Code | Condition | Body |
|---|---|---|
| 202 | Operation created | { "operation_id": "uuid", "status": "queued", "operator": "mtn" } |
| 401 | Missing or invalid signature | { "detail": "..." } |
| 409 | Duplicate X-Event-Id or request_id | { "detail": "..." } |
| 422 | Operator cannot be detected from MSISDN, or missing required field | { "detail": "..." } |
202{}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.
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.
{
"event": "operation.confirmed",
"is_terminal": true,
"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": true,
"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.
| Event | is_terminal | Additional fields |
|---|---|---|
operation.created | false | none |
operation.confirmed | true | sms_amounts, and ussd_response for balance operations |
operation.failed | true | outcome, failure_reason, ussd_response, or reason when cancelled by an agent |
operation.no_evidence | true | outcome, failure_reason, ussd_response |
sms.orphan | false | sim_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.
Available only when DEBUG=true. Not present in production.
Create fixed test fixtures: a test agent, device, and SIM.
X-API-Key| Fixture | Value |
|---|---|
| Agent login | test-agent |
| Agent password | test-password-123 |
| Device token | test-device-token-nexus-dashboard |
| SIM id | test-sim-mtn-01 |
| SIM MSISDN | 670000001 |
| SIM operator | mtn |
Response 200: { "status": "seeded" }
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Not found |
200{}Clear all operations, evidence rows, and sessions. Does not delete agents, devices, or SIMs.
X-API-KeyResponse 200: { "status": "reset" }
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Not found |
200{}Return the raw state the load balancer sees when selecting a SIM. Only active when DEBUG=true.
X-API-Key| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 404 | Not found |
200{}Liveness check. No auth required.
200{}If Redis is unavailable: "redis": "unavailable", "status": "degraded". The API continues to function using Postgres fallback.
| Code | Condition |
|---|---|
| 200 | Success |
Customer lookup for the board, read from the transfers Nexus already holds.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
q | query | string | Part of a name or a number. |
limit | query | integer | 1 to 25. Default 8 |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
200[
{
"contact_id": "contact-000001",
"name": "Awa N.",
"msisdn": "670000001"
}
]Customer payments recorded against WeGoPay, which a transaction is settled from.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
checked | query | boolean or null | |
search | query | string or null | Substring of the sender name, case insensitive. |
days | query | integer or null | How far back to look, in local calendar days. |
from | query | string or null | Local calendar day to start from, YYYY-MM-DD. Wins over days. |
to | query | string or null | Local calendar day to end on, inclusive of that whole day. |
limit | query | integer | 1 to 200. Default 50 |
offset | query | integer | At least 0. Default 0 |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 422 | The from day is after the to day |
200{
"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": false,
"source": "sms"
}
],
"total": 1,
"limit": 1,
"offset": 1
}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.
{
"payment_id": "payment-000001",
"sender": "<sender>",
"amount": 1.5,
"fees": 0,
"currency": "EUR",
"account_name": "Awa N.",
"account_type": "mtn_momo",
"payment_check": false
}| Field | Type | Required | Description |
|---|---|---|---|
payment_id | string | yes | Up to 120 characters |
sender | string | yes | Up to 200 characters |
amount | number | yes | Above 0 |
fees | number | no | At least 0. Default 0 |
currency | string | no | Up to 8 characters. Default "EUR" |
account_name | string or null | no | Up to 200 characters |
account_type | string or null | no | Up to 60 characters |
payment_check | boolean | no | Default false |
| Code | Condition |
|---|---|
| 201 | Created |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 409 | This payment is already recorded |
201{
"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": false,
"source": "sms"
}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.
| Parameter | In | Type | Description |
|---|---|---|---|
payment_id | path | integer |
{
"payment_check": false
}| Field | Type | Required | Description |
|---|---|---|---|
payment_check | boolean | yes |
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | The agent is inactive or no longer exists |
| 404 | Payment not found |
200{
"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": false,
"source": "sms"
}Committed changes announced to open boards as they happen, so no view waits for a poll.
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.
| Code | Condition |
|---|---|
| 200 | Success |
| 401 | Missing or invalid credentials |
| 403 | Agent not found or inactive |
Five rules enforced at the database level. Any implementation that violates them is incorrect.
- 1One execution per
request_id. Enforced by aUNIQUEconstraint on theoperationstable, not by application code. A second insert raises an integrity error and returns409. - 2One
runningoperation per device. Enforced by a partial unique index on(device_id) WHERE status='running'. The database prevents a second claim regardless of concurrency. - 3
no_evidenceauto-resolves only when a matching SMS arrives withinlate_evidence_minutes. After that window, onlyPOST /admin/operations/{id}/arbitratecan 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 = NULLstay indefinitely and remain visible in the arbitration queue.
| Variable | Purpose |
|---|---|
| DATABASE_URL | SQLAlchemy connection string. Default: sqlite:///./nexus.db. Production: Supabase Postgres URL. |
| NEXUS_API_KEY | Authenticates n8n. Not accepted on admin routes. Header: X-API-Key. |
| WEBHOOK_SECRET | HMAC key for outbound and inbound webhook signatures. |
| MASTER_KEY | 32-byte base64 key for AES-256-GCM PIN encryption. Never log. Never commit. |
| REDIS_URL | Redis connection URL. Omit to run without Redis (Postgres fallback active). |
| N8N_ENV | test or prod. Selects which n8n URL receives webhooks. Defaults to prod. |
| N8N_TEST_URL | n8n test path, active only while the editor canvas is listening. |
| N8N_PROD_URL | n8n production path, requires the workflow to be activated. |
| WEBHOOK_URL | Overrides the N8N_ENV selection. Empty everywhere disables delivery. |
| CORS_ALLOWED_ORIGINS | Comma-separated allowed origins. Default * in dev. Restrict in production. |
| EVIDENCE_WINDOW_MINUTES | Minutes an awaiting_evidence operation waits for its first SMS before expiring to no_evidence. Default: 15. |
| LATE_EVIDENCE_MINUTES | Minutes after expiry during which a matching SMS can still confirm a no_evidence operation. Default: 120. |
| DEVICE_COOLDOWN_SECONDS | Seconds after a finished operation before the device can receive another claim. Default: 5. |
| SMS_CLOCK_SKEW_SECONDS | Clock difference tolerated when comparing SMS receipt time with operation start time. Default: 60. |
| DEBUG | Set true to enable /v1/test/* routes. Never true in production. |
/v2