This is the full developer documentation for Xaalis
# Xaalis documentation
> Accept Wave and Orange Money in Senegal with one API. Start here.
Xaalis lets merchants in Senegal accept **Wave** and **Orange Money** through one API and a hosted checkout, then withdraw their balance with payouts. Amounts are always whole **F CFA (XOF)** — there are no decimals.
## Integrate
[Section titled “Integrate”](#integrate)
| Start with | Then |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Quickstart](/guides/quickstart/) — a test payment end to end in 5 minutes | [Payments](/guides/payments/) · [Webhooks](/guides/webhooks/) · [Payouts](/guides/payouts/) · [Refunds](/guides/refunds/) · [Subscriptions](/guides/subscriptions/) · [Agent wallets](/guides/agent-wallets/) |
| [Test mode](/guides/test-mode/) — what `sk_test_` keys can and can’t do | [Idempotency](/guides/idempotency/) · [Errors](/guides/errors/) · [Node SDK](/guides/sdk-node/) |
| [Merchant dashboard](/guides/dashboard/) — manage balance, team, and keys | [FAQ](/guides/faq/) · [Go-live checklist](/guides/go-live/) · [Security](/guides/security/) |
| [WooCommerce plugin](/guides/woocommerce/) — no code · [Example shop](/guides/example-shop/) | SDKs: [Node](/guides/sdk-node/) · [Python](/guides/sdk-python/) · [PHP](/guides/sdk-php/) |
| [AI agents and MCP](/guides/ai-agents/) — let Claude, ChatGPT, Cursor or your own agent use Xaalis | [Wallet exchange](/guides/wallet-exchange/) (test mode) · for LLMs: [`/llms.txt`](https://docs.xaalis.org/llms.txt) |
The **API reference** in the sidebar is generated from `openapi.json`, the contract the API publishes.
# Integrate Xaalis with a coding agent
> From an empty project to production with Claude Code, Cursor, Codex or any coding agent — what the agent can do on its own, and the steps that stay human (KYC, approval, live keys).
Xaalis is built so a coding agent can do an integration with little or no human help: the docs are machine-readable, a test account opens with one request, and every outcome can be simulated. Two things stay human by design — **identity verification (KYC)** and **going live** — so an agent can never start moving real money on its own.
## The brief
[Section titled “The brief”](#the-brief)
Paste this into your agent (Claude Code, Cursor, Codex, Gemini CLI…):
```text
Integrate Xaalis payments (Wave and Orange Money) into this project. First read
https://docs.xaalis.org/llms.txt, open a test account with
POST https://sandbox-api.xaalis.org/v1/sandbox/signups, then implement payment creation with an
idempotency key, the signed webhook and end-to-end tests. Use no live keys.
```
For a more reliable result, give it the **xaalis-integration** skill (Claude Code plugin) or paste `integrations/ai/AGENTS.xaalis.md` into your `AGENTS.md` — see [Skills for coding agents](/guides/ai-agents/#skills-for-coding-agents).
## What the agent does — test mode, no human needed
[Section titled “What the agent does — test mode, no human needed”](#what-the-agent-does--test-mode-no-human-needed)
| Step | How | Done when |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| 1. Learn the API | [`/llms.txt`](https://docs.xaalis.org/llms.txt), [`/llms-full.txt`](https://docs.xaalis.org/llms-full.txt), or the MCP tool `xaalis_search_docs` | — |
| 2. Open a test account | `POST https://sandbox-api.xaalis.org/v1/sandbox/signups` (or the MCP tool `xaalis_sandbox_signup`): returns `sk_test_…` and `whsec_…` **once** — see [Test mode](/guides/test-mode/#create-a-sandbox-account-from-code) | both secrets are in environment variables, never in code |
| 3. Create payments on the server | `POST /v1/payments` with an `Idempotency-Key` derived from the order, then redirect to `checkout_url` — see [Payments](/guides/payments/) | a test payment exists and the checkout opens |
| 4. Verify webhooks | raw body, `Xaalis-Signature` (HMAC-SHA256), reject timestamps older than 5 minutes, deduplicate on event `id` — see [Webhooks](/guides/webhooks/) | a wrong signature is rejected with `400` |
| 5. Fulfil the right way | only on a verified `payment.succeeded` webhook or a server-side `GET /v1/payments/{id}` — never on the redirect | the redirect alone ships nothing |
| 6. Simulate every outcome | `POST /v1/payments/{id}/simulate` with `succeeded` or `failed`; let one expire; a payout to a number ending in `0000` fails — see [Simulating outcomes](/guides/test-mode/#simulating-outcomes) | each outcome reaches the right order state |
| 7. Run the checklist | [Go-live checklist](/guides/go-live/) | every box is ticked |
## What stays human
[Section titled “What stays human”](#what-stays-human)
| Step | Why | Where |
| --------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Identity verification (KYC) | the business, the owner’s identity document and the Wave wallet that receives settlements | merchant dashboard, onboarding (owner only) — see [Dashboard](/guides/dashboard/) |
| Approval | Xaalis reviews the account; until it is active, live requests return `403 merchant_not_active` | [Go-live checklist](/guides/go-live/) |
| Live keys | an owner creates them in the dashboard; they are shown once | [Dashboard](/guides/dashboard/) |
| Reviewing the agent’s work | a person reads the integration before real money flows (checklist below) | — |
| Live agent wallets | if your app lets an agent pay people out, only an owner can create and fund its wallet | [Agent wallets](/guides/agent-wallets/) |
Today a production account is opened by the Xaalis team: ask for it once the checklist passes. The sandbox account the agent created stays test-only.
## Review the agent’s work before going live
[Section titled “Review the agent’s work before going live”](#review-the-agents-work-before-going-live)
* Secret keys and the webhook secret live only in server-side configuration — not in client code, a mobile app, a repository or logs (search the codebase for `sk_` and `whsec_`).
* Every `POST /v1/payments` and `POST /v1/payouts` sends an `Idempotency-Key` tied to the business action, reused on retry. → [Idempotency](/guides/idempotency/)
* The webhook handler reads the raw body, compares the signature in constant time, rejects old timestamps, deduplicates on event `id`, checks `livemode`, and compares `amount` and `client_reference` with the order.
* Amounts are whole XOF integers everywhere.
* Nothing is fulfilled because the customer reached `success_url`.
* Errors are handled by `code`, not by message. → [Errors](/guides/errors/)
* The base URL, keys and webhook secret come from configuration, so going live is a configuration change.
## Switch to live
[Section titled “Switch to live”](#switch-to-live)
Swap `https://sandbox-api.xaalis.org` for `https://api.xaalis.org`, the `sk_test_` key for the `sk_live_` one, and the webhook secret for the production account’s. Make one small real payment (100 F CFA) and watch the webhook arrive in `GET /v1/webhook-deliveries`. The rest is in the [Go-live checklist](/guides/go-live/).
# Agent wallets
> Let an AI agent or automated service pay out safely — a funded wallet with its own key and hard limits, in test and live mode.
An **agent wallet** lets an AI agent or an automated service send payouts **without your merchant key**. You fund it with part of your balance, and give the agent a wallet key (`ak_test_…` or `ak_live_…`) that can only:
* read its own wallet and balance, and
* create payouts **within a fixed policy**: one provider, a maximum per payout, an optional **daily** maximum (`max_daily_amount`, counted per Dakar calendar day), and an allowlist of recipient phones (1–20).
The wallet’s funded balance is the agent’s total spending limit. It can never touch the rest of your balance.
| You (merchant key) | The agent (wallet key) |
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `POST /v1/agent-wallets` — create with a policy (`Idempotency-Key` required) | `GET /v1/agent/wallet` — its policy |
| `POST /v1/agent-wallets/{id}/fund` · `…/reclaim` — move money in / take unreserved money back | `GET /v1/agent/balance` |
| `POST /v1/agent-wallets/{id}/keys` — issue a key (shown **once**) | `POST /v1/agent/payouts` — pay out within the policy |
| `POST /v1/agent-wallets/{id}/disable` — stop new agent writes | `GET /v1/agent/payouts` · `…/{id}` |
| `GET /v1/agent-wallets/{id}/payouts` · `…/transfers` · `…/keys` | |
## Safe retries: `action_id`
[Section titled “Safe retries: action\_id”](#safe-retries-action_id)
Every agent payout carries an `action_id` from **your application** (e.g. the approved task’s id), not invented by the model. Xaalis binds one payout to each `action_id` in the wallet, so a retried tool call returns the original payout instead of paying twice. Reusing an `action_id` with a different amount or recipient returns `409`.
## Live wallets: set up by a person
[Section titled “Live wallets: set up by a person”](#live-wallets-set-up-by-a-person)
In live mode, only a signed-in **owner** of the merchant account can create a wallet, change its policy, fund or reclaim it, and issue its `ak_live_` key — in the merchant dashboard, never with an API key. So an agent holding your `sk_live_` key (or any API key) can’t create or top up a wallet for itself. Your live API keys can still **read** wallets and **disable** a wallet or revoke its key — stopping an agent is always one call away.
The owner can raise or lower the per-payout and daily caps or replace the recipient list at any time; the next payout is checked against the new policy.
## Limits of this version
[Section titled “Limits of this version”](#limits-of-this-version)
* Live agent payouts support **Wave** wallets only for now (Orange Money payouts aren’t available yet).
* A live agent key is only accepted over HTTPS.
* Disabling a wallet blocks new agent writes; payouts already accepted still finish.
The TypeScript SDK includes agent-wallet helpers (`xaalis.agentWallets` for you, an agent client with `payouts.createForAction` for the agent), with an example tool handler.
# AI agents and MCP
> Let AI agents use Xaalis — the MCP server (hosted or local), setup for Claude, ChatGPT, Cursor, VS Code, Codex, Gemini and more, Agent Skills, framework tools, and the safety model.
Any AI agent can use Xaalis: learn the API, open a sandbox account, collect Wave and Orange Money payments, refund, bill subscriptions and pay people out — with little or no human help, and without ever being able to move live money beyond what a human allowed.
| You want… | Use |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| an assistant (Claude, ChatGPT, Cursor, VS Code, Codex…) to **operate** Xaalis for you | the **MCP server** — [connect your client](#connect-your-client) |
| a coding agent to **integrate** Xaalis into your app | the **Agent Skills** (or the Claude Code plugin) + the MCP server for docs and test objects |
| your own agent (OpenAI, Anthropic, Vercel AI SDK, LangChain, Python) to call Xaalis | the **tool catalog** — [frameworks](#your-own-agent-no-mcp) |
LLM-readable docs: [`/llms.txt`](https://docs.xaalis.org/llms.txt) · [`/llms-full.txt`](https://docs.xaalis.org/llms-full.txt).
Rolling out
The hosted server (`https://mcp.xaalis.org/mcp` and `/sandbox`) and sign-in with your Xaalis dashboard account are live. The npm packages `@xaalis/mcp` and `@xaalis/ai-tools` are being published: until then, use the hosted server.
## What an agent can do — the credential decides
[Section titled “What an agent can do — the credential decides”](#what-an-agent-can-do--the-credential-decides)
The MCP server only **lists** the tools the connected credential allows, so a model can’t call what it can’t see — the first defence against a prompt injection asking it to “just send a payout”. The API enforces the same limits again.
| Credential | Tools |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| none | `xaalis_whoami`, `xaalis_search_docs`, `xaalis_sandbox_signup` (creates a sandbox account; the session then uses it) |
| `sk_test_…` sandbox | everything, with fake money: payments + `xaalis_simulate_payment`, refunds, subscriptions + `xaalis_advance_subscription`, payouts, agent-wallet setup |
| `sk_live_…` | read everything; create payments and subscriptions (money **in**); cancel subscriptions; disable agent wallets. **No payouts.** Refunds only if a human turned them on |
| `rk_test_…` / `rk_live_…` restricted key, or an **OAuth** sign-in | the same, narrowed to the **scopes** a human granted in the dashboard (e.g. `payments:read` + `payments:write` only). Refunds appear only if `refunds:write` was granted |
| `ak_test_…` / `ak_live_…` agent wallet | its own policy and balance, and payouts **within** the per-payout cap, daily cap, recipient list and balance a human set |
Test credentials go to the sandbox API, live ones to production — automatically.
**Least privilege:** for an agent in production, prefer a **restricted key** (dashboard → API keys → restricted, pick the scopes) or an OAuth sign-in over a full `sk_live_` key. Scopes: `payments:read`, `payments:write`, `refunds:write`, `subscriptions:read`, `subscriptions:write`, `payouts:read`, `balance:read`, `webhooks:read`, `agent_wallets:read`, `agent_wallets:write` (and `payouts:write`, `webhooks:write`, which the MCP tools don’t use).
**Live refunds** with a full `sk_live_` key are off unless the human who sets up the connection turns them on: header `X-Xaalis-Allow-Live-Refunds: true` (hosted) or `XAALIS_ALLOW_LIVE_REFUNDS=true` (local). With a restricted key or OAuth, granting `refunds:write` is that decision. The model can change neither.
**Money out** is done only by agent wallets: a separate balance (the agent’s total budget), a per-payout cap, an optional daily cap and an allow-list of phone numbers, with a key that can do nothing else. Retrying the same `action_id` never pays twice. In production, only a dashboard **owner** can create, fund and issue keys for a live agent wallet — never an API key, so an agent can’t fund itself. See [Agent wallets](/guides/agent-wallets/).
## Rules every agent follows
[Section titled “Rules every agent follows”](#rules-every-agent-follows)
The server sends these as MCP instructions; the skills repeat them.
1. Amounts are whole XOF integers (`15000`), never decimals.
2. Every write takes an `idempotency_key` derived from the business action; retries reuse it.
3. A payment is paid only when it reads `succeeded` (or a signed webhook says so) — never because of a redirect.
4. Test keys are fake money. With a live key, confirm amount and purpose with the human before any write.
5. Money goes out only through agent wallets.
6. `outcome_unknown: true` → read the object or retry with the same key; never create a second one.
7. Secret keys stay in environment variables or a secret store — never in chat, code or logs.
## Zero-human start (sandbox)
[Section titled “Zero-human start (sandbox)”](#zero-human-start-sandbox)
Connect **without a key**. The agent calls `xaalis_sandbox_signup` with an email and a business name and gets a sandbox account (`sk_test_…` key + webhook secret). The same session switches to it, so the agent can go straight on: create a payment, simulate the customer paying, refund it, set up a subscription.
* Local server: the session keeps the key until the process stops.
* Hosted server: connect to **`https://mcp.xaalis.org/sandbox`** with no credential. The session (`Mcp-Session-Id`) keeps the sandbox key in memory for up to 30 idle minutes. Clients set to “no authentication” (ChatGPT, claude.ai) use this; the sandbox tools are listed from the start and answer `signup_required` until the signup.
* To keep the account, store the returned `secret_key` as `XAALIS_API_KEY` and reconnect with it.
Going live always needs a human: identity checks (KYC), approval, then a live key.
## Connect your client
[Section titled “Connect your client”](#connect-your-client)
| Hosted endpoint | Credential |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `https://mcp.xaalis.org/mcp` | `Authorization: Bearer ` (`sk_`, `rk_`, `ak_`), or **OAuth**: without a credential it answers `401` and OAuth-capable clients open the Xaalis sign-in, where you pick the account, test or live, and the scopes |
| `https://mcp.xaalis.org/sandbox` | none — the zero-human sandbox session |
Local: `npx -y @xaalis/mcp` (Node 20+) with the key in `XAALIS_API_KEY`. Omit the key for the zero-human start.
### Claude Code
[Section titled “Claude Code”](#claude-code)
```bash
claude mcp add --transport http xaalis https://mcp.xaalis.org/mcp --header "Authorization: Bearer $XAALIS_API_KEY"
# or local:
claude mcp add --env XAALIS_API_KEY=$XAALIS_API_KEY --transport stdio xaalis -- npx -y @xaalis/mcp
```
Or install the **plugin** (MCP server + skills; the key is asked once and kept in your OS credential store):
```text
/plugin marketplace add
/plugin install xaalis@xaalis
```
Project `.mcp.json` (shared with your team — the key stays in each person’s environment):
```json
{
"mcpServers": {
"xaalis": { "type": "http", "url": "https://mcp.xaalis.org/mcp", "headers": { "Authorization": "Bearer ${XAALIS_API_KEY}" } }
}
}
```
### Claude Desktop and claude.ai
[Section titled “Claude Desktop and claude.ai”](#claude-desktop-and-claudeai)
Local server — `claude_desktop_config.json` (macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`):
```json
{ "mcpServers": { "xaalis": { "command": "npx", "args": ["-y", "@xaalis/mcp"], "env": { "XAALIS_API_KEY": "sk_test_…" } } } }
```
Remote **custom connector** (Customize → Connectors → Add custom connector):
* your account: URL `https://mcp.xaalis.org/mcp` — Claude opens the Xaalis sign-in (OAuth). You choose the account, test or live, and what the connector may do (scopes). Withdrawing that consent takes effect immediately.
* no account yet: URL `https://mcp.xaalis.org/sandbox` with **no sign-in** — the zero-human sandbox session.
### ChatGPT
[Section titled “ChatGPT”](#chatgpt)
Plugins → **Add** → **Add custom MCP server** (no developer mode needed any more):
* your account: URL `https://mcp.xaalis.org/mcp`, authentication **OAuth** — ChatGPT discovers the Xaalis sign-in by itself; you choose the account, test or live, and the scopes.
* no account yet: URL `https://mcp.xaalis.org/sandbox`, **No authentication** — the agent searches the docs, opens a sandbox account and uses it in that conversation.
### What the Xaalis sign-in asks (OAuth)
[Section titled “What the Xaalis sign-in asks (OAuth)”](#what-the-xaalis-sign-in-asks-oauth)
Checked end to end with Claude.ai and ChatGPT on 2026-10-14:
* **Only an owner** of the business can authorize a connector; a teammate with another role sees “Autorisation non disponible”.
* You pick the business and **test or live** (live only once the business is activated), then untick any permission you don’t want to give — e.g. keep `payments:read`, `balance:read` and `payments:write`, and leave refunds, payouts and agent-wallet writes off. The connector gets exactly what stays ticked.
* Approve within **10 minutes** of clicking Connect; after that the request expires (“Erreur de continuation OAuth”) and you start again from Connect.
* Access tokens last 5 minutes and are renewed in the background (`offline_access`). **Settings → Applications & IA → Révoquer l’accès** cuts the connector off at once — its next call fails even with an unexpired token.
### Cursor
[Section titled “Cursor”](#cursor)
`.cursor/mcp.json` (project) or `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"xaalis": { "url": "https://mcp.xaalis.org/mcp", "headers": { "Authorization": "Bearer ${env:XAALIS_API_KEY}" } }
}
}
```
Local instead: `{ "command": "npx", "args": ["-y", "@xaalis/mcp"], "env": { "XAALIS_API_KEY": "${env:XAALIS_API_KEY}" } }`.
### VS Code (GitHub Copilot)
[Section titled “VS Code (GitHub Copilot)”](#vs-code-github-copilot)
`.vscode/mcp.json` — note the top-level key `servers`; VS Code asks for the key once and stores it:
```json
{
"inputs": [{ "type": "promptString", "id": "xaalis-key", "description": "Xaalis API key", "password": true }],
"servers": {
"xaalis": { "type": "http", "url": "https://mcp.xaalis.org/mcp", "headers": { "Authorization": "Bearer ${input:xaalis-key}" } }
}
}
```
### OpenAI Codex CLI
[Section titled “OpenAI Codex CLI”](#openai-codex-cli)
`~/.codex/config.toml`:
```toml
[mcp_servers.xaalis]
url = "https://mcp.xaalis.org/mcp"
bearer_token_env_var = "XAALIS_API_KEY"
# or local:
# [mcp_servers.xaalis]
# command = "npx"
# args = ["-y", "@xaalis/mcp"]
# env_vars = ["XAALIS_API_KEY"]
```
### Gemini CLI
[Section titled “Gemini CLI”](#gemini-cli)
`~/.gemini/settings.json` or `.gemini/settings.json`:
```json
{
"mcpServers": {
"xaalis": { "httpUrl": "https://mcp.xaalis.org/mcp", "headers": { "Authorization": "Bearer $XAALIS_API_KEY" } }
}
}
```
### Windsurf / Devin Desktop
[Section titled “Windsurf / Devin Desktop”](#windsurf--devin-desktop)
`~/.config/devin/mcp_config.json` (older Windsurf: `~/.codeium/windsurf/mcp_config.json`):
```json
{
"mcpServers": {
"xaalis": { "serverUrl": "https://mcp.xaalis.org/mcp", "headers": { "Authorization": "Bearer ${env:XAALIS_API_KEY}" } }
}
}
```
### Zed
[Section titled “Zed”](#zed)
`settings.json`:
```json
{
"context_servers": {
"xaalis": { "command": "npx", "args": ["-y", "@xaalis/mcp"], "env": { "XAALIS_API_KEY": "sk_test_…" } }
}
}
```
### OpenAI API and Agents SDK
[Section titled “OpenAI API and Agents SDK”](#openai-api-and-agents-sdk)
Responses API — OpenAI calls the tools itself:
```ts
await openai.responses.create({
model: "gpt-5",
input: "Create a 15000 F CFA payment for 'Tissus wax' and give me the link.",
tools: [{
type: "mcp",
server_label: "xaalis",
server_url: "https://mcp.xaalis.org/mcp",
headers: { Authorization: `Bearer ${process.env.XAALIS_API_KEY}` },
require_approval: "always", // keep "always" with live keys
}],
});
```
Agents SDK: `new MCPServerStreamableHttp({ url: "https://mcp.xaalis.org/mcp", requestInit: { headers: { Authorization: … } } })` (TypeScript) or `MCPServerStreamableHttp(params={"url": …, "headers": {…}})` (Python).
### Anthropic API
[Section titled “Anthropic API”](#anthropic-api)
MCP connector (beta header `mcp-client-2025-11-20`):
```json
{
"mcp_servers": [{ "type": "url", "url": "https://mcp.xaalis.org/mcp", "name": "xaalis", "authorization_token": "sk_test_…" }],
"tools": [{ "type": "mcp_toolset", "mcp_server_name": "xaalis" }]
}
```
Complete, runnable versions of these: `integrations/ai/examples/` in the Xaalis repository.
## Skills for coding agents
[Section titled “Skills for coding agents”](#skills-for-coding-agents)
Two [Agent Skills](https://agentskills.io/specification), in the Claude Code plugin and usable by any skills-aware agent:
* **xaalis-integration** — add Xaalis to an app: server-side payment creation, redirect, webhook verification (Node, Python, PHP, Go), fulfilment, sandbox tests, refunds, subscriptions, payouts, go-live.
* **xaalis-operations** — run a business through the MCP tools: collect and confirm, refund, subscriptions, balance, webhook debugging, agent-wallet payouts, error handling.
For agents that read `AGENTS.md` (Codex, Gemini CLI, Copilot, Jules…), paste the ready-made section `integrations/ai/AGENTS.xaalis.md` into yours. Cursor: `integrations/ai/cursor/xaalis.mdc` → `.cursor/rules/`.
## Your own agent (no MCP)
[Section titled “Your own agent (no MCP)”](#your-own-agent-no-mcp)
The same tool catalog runs in-process: you pass the tool definitions to your model and execute its calls.
**TypeScript** — `@xaalis/ai-tools`:
```ts
import { AGENT_RULES, XaalisToolkit } from "@xaalis/ai-tools";
import { openAITools, runOpenAIToolCall } from "@xaalis/ai-tools/openai"; // Responses / Chat Completions
import { anthropicTools, runAnthropicToolUse } from "@xaalis/ai-tools/anthropic";
import { vercelTools } from "@xaalis/ai-tools/vercel"; // AI SDK: tools: vercelTools(kit)
import { langChainTools } from "@xaalis/ai-tools/langchain"; // needs @langchain/core
const kit = new XaalisToolkit({ apiKey: process.env.XAALIS_API_KEY }); // no key: docs + sandbox signup
// restricted key (rk_) or OAuth token: const kit = await XaalisToolkit.connect({ apiKey }) — loads its scopes
const tools = openAITools(kit); // pass to the model
const output = await runOpenAIToolCall(kit, call.name, call.arguments); // run what it calls
```
**Python** — `xaalis.ai` (standard library only):
```python
from xaalis.ai import AGENT_RULES, XaalisToolkit
kit = XaalisToolkit(os.environ.get("XAALIS_API_KEY"), allow_live_refunds=False)
tools = kit.openai_tools() # or openai_chat_tools(), anthropic_tools(), langchain_tools()
output = kit.run_for_model(call.name, call.arguments)
```
Results are JSON; errors come back as `{"error": {"code", "message", "hint", "outcome_unknown"}}` so the model knows the next step. After a sandbox signup, rebuild the tool list (`kit.tools()` changes).
## Tool reference
[Section titled “Tool reference”](#tool-reference)
Keys column: `sk` = merchant keys (and restricted keys / OAuth with the matching scope), `ak` = agent-wallet keys.
| Tool | Keys | Does |
| --------------------------------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------- |
| `xaalis_whoami` | all | connected key, mode, account/wallet, available tools |
| `xaalis_search_docs` | all | search this documentation |
| `xaalis_sandbox_signup` | none | create a sandbox account (email, business name) |
| `xaalis_create_payment` · `_get_payment` · `_list_payments` | sk | payment + `checkout_url`; status |
| `xaalis_simulate_payment` | sk\_test | make a test payment succeed or fail |
| `xaalis_refund_payment` · `_list_refunds` | sk (live: if allowed) | full refund of a Wave payment; check it |
| `xaalis_get_balance` | sk | available and reserved XOF |
| `xaalis_create_payout` | sk\_test | payout from the merchant balance (sandbox) |
| `xaalis_get_payout` · `_list_payouts` | sk | payout status |
| `xaalis_list_webhook_deliveries` | sk | recent webhooks, attempts, last error |
| `xaalis_create_subscription` · `_get_subscription` · `_list_subscriptions` · `_cancel_subscription` | sk | subscriptions and instalment plans |
| `xaalis_advance_subscription` | sk\_test | jump to the next billing event |
| `xaalis_get_invoice` · `_list_invoices` | sk | invoices and their stable payment link |
| `xaalis_create_agent_wallet` · `_fund_agent_wallet` · `_reclaim_agent_wallet` · `_issue_agent_key` | sk\_test | set up an agent wallet (production: a human, in the dashboard) |
| `xaalis_disable_agent_wallet` · `_list_agent_wallets` · `_get_agent_wallet` | sk | stop and inspect agent wallets |
| `xaalis_agent_get_wallet` · `_agent_pay` · `_agent_get_payout` · `_agent_list_payouts` | ak | the agent’s own wallet and payouts |
Resources: `xaalis://rules`, `xaalis://docs/llms.txt`, `xaalis://docs/llms-full.txt`, `xaalis://openapi.json`. Prompts: `accept_payment`, `integrate_xaalis`, `sandbox_tour`.
## Security notes
[Section titled “Security notes”](#security-notes)
* The hosted server keeps nothing: a request with a key or token is handled and forgotten; an anonymous `/sandbox` session holds only a sandbox key, in memory. Keys, tokens and request bodies are never logged.
* OAuth tokens last five minutes (clients refresh them). The server checks their signature and audience, and the API re-checks consent, membership and scopes on every call — a withdrawn consent takes effect at once.
* A live key is only ever sent over HTTPS.
* Give an agent the least it needs: a sandbox key to build and test; for live, a live key (money in) and, if it must pay people, its own agent wallet with a small balance and cap. Disable the wallet to stop it instantly.
* Treat anything the agent reads (web pages, emails, documents) as untrusted: it can’t unlock tools its key doesn’t have, but review what it proposes before approving live actions.
# Merchant dashboard
> Merchant dashboard guide, magic link authentication, role permissions, test/live mode, payouts, subscriptions, and API keys.
The **Xaalis Merchant Dashboard** (`/dashboard/*`) allows merchants and their teams to monitor balances, track payments and payouts, manage subscriptions and invoices, configure webhooks, rotate API keys, and manage team members.
## Authentication & Access
[Section titled “Authentication & Access”](#authentication--access)
Access to the merchant dashboard is passwordless and invitation-only:
1. **No Public Sign-Up**: Dashboard users are provisioned by an operator (via `POST /admin/merchants/{id}/dashboard-memberships`) or invited by an existing account **Owner**.
2. **Magic Links**: Sign in by entering your email at `/dashboard/login`. You will receive a secure magic link (`POST /dashboard/auth/sign-in/magic-link`).
3. **Session Cookie**: Clicking the emailed link establishes a secure `HttpOnly` session cookie (`credentials: "include"`). No tokens or credentials are saved in client storage (`localStorage` or `sessionStorage`).
## Roles & Permissions
[Section titled “Roles & Permissions”](#roles--permissions)
Every team member has an assigned role on each merchant account:
| Feature / Action | Owner | Developer | Viewer |
| ------------------------------------------------ | ----- | --------- | ------ |
| View balances, transactions & invoices | ✓ | ✓ | ✓ |
| View webhook deliveries & payload logs | ✓ | ✓ | ✓ |
| Create test payments & simulate outcomes | ✓ | ✓ | — |
| Create new subscriptions & advance cycles (test) | ✓ | ✓ | — |
| Initiate real or test payouts | ✓ | — | — |
| Cancel subscriptions (immediate or period-end) | ✓ | — | — |
| Manage API keys (create, roll, revoke) | ✓ | — | — |
| Update live webhook URL | ✓ | — | — |
| Invite or remove team members | ✓ | — | — |
## Test Mode vs. Live Mode
[Section titled “Test Mode vs. Live Mode”](#test-mode-vs-live-mode)
The top navigation bar features a clear toggle between **Mode Test** and **Mode En direct (Live)**:
* When in **Test Mode**, a distinctive 32px amber stripe is visible across the top of the interface. All displayed data, transactions, and balances belong strictly to the `test:` ledger.
* Actions executed in Test Mode (e.g. creating test payments, simulating approvals/failures, rolling test keys) will never touch real money or provider rails.
* You can switch seamlessly between modes at any time; your permissions remain identical across both modes.
## Balance & Payouts (Solde & Virements)
[Section titled “Balance & Payouts (Solde & Virements)”](#balance--payouts-solde--virements)
### Balance strip
[Section titled “Balance strip”](#balance-strip)
The dashboard prominently displays two figures at the top:
* **Disponible (Available)**: Funds settled and cleared into your merchant account, ready for immediate payout.
* **Virements en cours (In transit)**: Funds reserved for payouts currently being processed by the provider connector.
### Initiating a payout
[Section titled “Initiating a payout”](#initiating-a-payout)
1. Click **Virer les fonds** from the balance strip or the Payouts page.
2. Select your mobile provider (**Wave** or **Orange Money**).
3. Enter the recipient mobile number (validated in the Senegalese standard format: `+2217XXXXXXXX`).
4. Enter the amount in integer XOF (must be $\le$ Available balance minus the fee).
5. Review the payout summary and fee before confirming.
6. The request is dispatched with an automatic, unique `Idempotency-Key` header to prevent duplicate transfers.
## Payments & Test Simulation
[Section titled “Payments & Test Simulation”](#payments--test-simulation)
* **Overview**: Real-time table displaying transaction date, customer details, client reference, gross amount, and fee.
* **Status Filters**: Filter payments by `succeeded`, `processing`, `requires_payment_method`, `failed`, or `expired`.
* **Detail Drawer**: Click any payment row to open a full slide-over panel displaying the exact Xaalis payment ID, customer info, fee breakdown, net payout amount, and direct link to the customer’s hosted checkout.
* **Simulation**: In Test Mode, payments in `processing` or `requires_payment_method` can be simulated as `succeeded` or `failed` directly from the drawer to test your webhook integrations.
## Subscriptions & Invoicing (Abonnements & Factures)
[Section titled “Subscriptions & Invoicing (Abonnements & Factures)”](#subscriptions--invoicing-abonnements--factures)
The Subscriptions section enables management of recurring customer billing:
* **Creating Subscriptions**: Configure recurring plans with intervals (`weekly`, `monthly`, `quarterly`, `semi_annually`, `yearly`), billing cycles, customer details, and payment grace periods (`days_until_due`).
* **Invoices**: Every cycle generates an invoice with a dedicated customer checkout URL (`hosted_invoice_url`). Invoices display clear statuses: `draft`, `open`, `paid`, `overdue`, or `uncollectible`.
* **Cancellation**:
* **At period end** (`at_period_end: true`): The current billing period remains active and payable; no subsequent renewals will be scheduled.
* **Immediate** (`at_period_end: false`): Immediately cancels the subscription. If a payment is currently processing with a provider, it settles cleanly while future recurring billing ceases.
* **Testing Cycle Advances**: In Test Mode, developers can trigger an immediate cycle advance to test recurring renewal notifications and invoice generation.
## Developer Settings & Security
[Section titled “Developer Settings & Security”](#developer-settings--security)
Located under `/dashboard/developers`, this section is tailored for engineering teams:
### API Keys & Scopes (Clés d’API & Permissions)
[Section titled “API Keys & Scopes (Clés d’API & Permissions)”](#api-keys--scopes-clés-dapi--permissions)
* Lists all active and scheduled-for-revocation keys for the current mode.
* Only safe identifiers (`key_...`), display prefixes (`sk_test_...`, `sk_live_...`, `rk_test_...`, or `rk_live_...`), and safe timestamps are shown.
* **Full vs. Restricted Keys**:
* **Standard Keys (`sk_...`)**: Full administrative access across all merchant endpoints (`*`).
* **Restricted Keys (`rk_...`)**: Explicit, granular scopes (`payments:read`, `payments:write`, `refunds:write`, `subscriptions:read`, `subscriptions:write`, `payouts:read`, `payouts:write`, `balance:read`, `webhooks:read`, `webhooks:write`, `agent_wallets:read`, `agent_wallets:write`). Allows handing keys to microservices or AI agents with zero risk of unauthorized refunds or transfers.
* **One-Time Secret Display**: Secret keys are displayed **exactly once** in a secure modal with a 1-click copy action. Secret keys are never persisted in browser storage, analytics, URLs, or client logs.
* **Zero-Downtime Rotation**: Rolling a key configures a grace period between 1 and 72 hours (default 24h). The old key remains valid until `scheduled_revoke_at`, allowing updates to production servers without payment downtime.
* **Immediate Revocation**: Compromised or decommissioned keys can be revoked instantly.
### Webhooks & Multiple Destinations (WA-1, WA-1b)
[Section titled “Webhooks & Multiple Destinations (WA-1, WA-1b)”](#webhooks--multiple-destinations-wa-1-wa-1b)
Located under `/dashboard/developers?tab=webhooks`, webhook management supports:
* **Default Endpoint & Multiple Destinations (WA-1)**: Every account maintains a default webhook endpoint and up to 9 custom destinations per environment (`test` or `live`).
* **Granular Event Filtering**: Route specific event types to different internal services (`payment.succeeded`, `payment.failed`, `payout.succeeded`, `invoice.paid`, etc.).
* **Data Privacy Policies (Payload Policy)**:
* `full`: Delivers the complete JSON resource object.
* `minimal`: Sends only the event ID, merchant ID, and resource ID/type, requiring the receiver to re-fetch the resource over the authenticated API before processing sensitive consumer data.
* **Dedicated Signing Secrets**: Each destination generates its own immutable signing secret (`whsec_...`) displayed **once** upon creation.
* **Service-Managed WhatsApp Endpoints (WA-1b)**: Endpoints managed by the internal WhatsApp service are marked `Géré automatiquement` (`managed_by: "whatsapp"`) and cannot be modified or deleted manually.
## WhatsApp Merchant Assistant & Payouts (WA-2, WA-3, WA-3b)
[Section titled “WhatsApp Merchant Assistant & Payouts (WA-2, WA-3, WA-3b)”](#whatsapp-merchant-assistant--payouts-wa-2-wa-3-wa-3b)
Located under `/dashboard/settings?tab=whatsapp`, this feature allows merchants to monitor their business and issue secure payouts directly over WhatsApp:
### Account Linking (WA-2)
[Section titled “Account Linking (WA-2)”](#account-linking-wa-2)
* Any team member can link their Senegalese WhatsApp number (`+221...`) by generating a **unique 8-character code** valid for 10 minutes.
* Once the code is texted from WhatsApp, the user confirms the connection in the dashboard.
* Toggle live payment notifications (`notifications.payment_succeeded`) on or off at will.
* Owners can inspect all active connections across the business; team members manage their own.
### Secure WhatsApp Payouts & PIN (WA-3, WA-3b)
[Section titled “Secure WhatsApp Payouts & PIN (WA-3, WA-3b)”](#secure-whatsapp-payouts--pin-wa-3-wa-3b)
Business owners can initiate quick payouts through WhatsApp while maintaining strict financial security:
* **6-Digit PIN Validation**: Confirmations occur strictly on a standalone secure Xaalis web page (`/wa/pin/[token]`), never inside the chat conversation.
* **Argon2id & Pepper**: PINs are hashed using Argon2id with an isolated deployment pepper (`WHATSAPP_PIN_PEPPER`).
* **3-Attempt Lockout**: Three consecutive incorrect entries lock WhatsApp payouts for 24 hours.
* **24-Hour Security Hold**: All new links, PIN resets, account unlocks, and daily cap raises are subject to an irreversible 24-hour waiting period.
* **Configurable Daily Cap**: Daily spending limit (in integer XOF) that automatically resets at midnight Dakar time.
* **Approved Settlement Only**: WhatsApp payouts are restricted exclusively to the pre-approved Wave settlement wallet registered during onboarding.
* **5-Minute Single-Use Links**: Confirmation tokens expire in 5 minutes and become permanently invalid once used.
## Operator Ergonomics & Keyboard Shortcuts
[Section titled “Operator Ergonomics & Keyboard Shortcuts”](#operator-ergonomics--keyboard-shortcuts)
The dashboard is engineered for high-velocity operations with full keyboard control:
### Global Shortcuts
[Section titled “Global Shortcuts”](#global-shortcuts)
* **`⌘K` / `Ctrl+K`**: Open the universal Command Palette to search pages, resources, or trigger actions.
* **`?`**: Display the shortcuts cheat-sheet modal from anywhere.
* **`m`**: Instant toggle between Test Mode and Live Mode.
* **Navigation Chords (`g` followed by…)**:
* `g` then `h`: Overview (Home)
* `g` then `p`: Payments & Transactions
* `g` then `s`: Subscriptions & Invoices
* `g` then `o`: Payouts (Withdrawals)
* `g` then `a`: Autonomous AI Agent Wallets
* `g` then `d`: Developers & API
* `g` then `t`: Merchant Settings
### Table & List Navigation
[Section titled “Table & List Navigation”](#table--list-navigation)
* **`j` / `↓`**: Move focus to next row.
* **`k` / `↑`**: Move focus to previous row.
* **`Enter`**: Open detail drawer or inspect the focused transaction.
* **`c`**: Copy technical identifier (`pay_...`, `po_...`, `sub_...`) to clipboard.
* **`r`**: Refresh current data live with a visual indicator.
* **`e`**: Export currently filtered list to CSV.
* **`/`**: Instantly jump cursor into the search filter input.
* **`1`, `2`, `3`…**: Switch sub-tabs in Developers, Subscriptions, and Settings.
## Payment Refunds (Remboursements)
[Section titled “Payment Refunds (Remboursements)”](#payment-refunds-remboursements)
Account **Owners** can refund settled transactions directly from `/dashboard/payments`:
* **Eligibility**: Currently supported on completed **Wave** transactions.
* **Gross Deduction**: Refunds deduct the full gross amount from your available balance. The standard Xaalis fee policy determines whether fees are absorbed or retained.
* **Insufficient Funds (`422`)**: If your available balance is insufficient to cover the gross refund amount, the dashboard clearly alerts you to receive further customer payments or deposit funds before retrying.
* **Durable Refund States**: Refund outcomes are tracked as `pending`, `needs_review`, or `succeeded`. A succeeded refund marks the payment as `refunded` in real-time.
## Autonomous Agent Wallets (Agents IA)
[Section titled “Autonomous Agent Wallets (Agents IA)”](#autonomous-agent-wallets-agents-ia)
Located at `/dashboard/agent-wallets`, this owner-only section empowers merchants to safely integrate autonomous AI agents:
* **Dedicated Sub-Wallets**: Each agent receives an isolated ledger balance with explicit spending rules.
* **Daily Spending Limits**: Configure a `max_daily_amount` enforced strictly in `Africa/Dakar` timezone. Admitted payouts reset at midnight Dakar time.
* **One-Time Agent Keys (`ak_test_...` / `ak_live_...`)**: Issue dedicated credentials for agents using `@xaalis/sdk` `XaalisAgentClient`.
* **Wallet Controls**: Fund wallets from your merchant available balance, or immediately disable a compromised agent to freeze its payout capability.
## Merchant Settings & Compliance
[Section titled “Merchant Settings & Compliance”](#merchant-settings--compliance)
Located at `/dashboard/settings`, this area provides complete administrative control for account owners:
### KYC & Live Onboarding
[Section titled “KYC & Live Onboarding”](#kyc--live-onboarding)
Merchants transitioning to live processing must complete business verification:
1. **Business Profile**: Submit legal business name, NINEA, and RCCM registry numbers.
2. **Settlement Wallet**: Specify your verified Wave mobile wallet (`+2217XXXXXXXX`) for automated funds settlement.
3. **Identity Verification**: Upload a valid government-issued ID or business registration certificate (PDF, JPEG, or PNG up to 5 MiB). Uploads stream directly as raw bytes without persisting in browser memory.
4. **Review Status**: Track status across `draft`, `submitted`, `approved`, or `rejected`. If rejected, detailed reviewer notes explain required corrections.
### Automatic Settlement (Règlement Automatique)
[Section titled “Automatic Settlement (Règlement Automatique)”](#automatic-settlement-règlement-automatique)
* Configure automatic daily payouts of your cleared balance directly to your approved Wave wallet.
* **Schedule**: Fixed daily sweep executed at **18:00 Africa/Dakar**.
* **Threshold**: Set a minimum payout threshold (`min_amount`) in net XOF. If available balance is below this threshold at 18:00, funds roll over to the following day.
* **Audit & History**: Track `last_run` timestamp and payout delivery status.
### Connected AI Applications (OAuth 2.1)
[Section titled “Connected AI Applications (OAuth 2.1)”](#connected-ai-applications-oauth-21)
* Review and audit third-party AI connectors (e.g. ChatGPT, Claude.ai) connected via `https://mcp.xaalis.org`.
* Displays client application names, assigned scopes, and grant dates.
* **Instant Revocation**: Revoke consent at any time. Revocation immediately terminates active access tokens and blocks subsequent tool invocations.
### Team Memberships
[Section titled “Team Memberships”](#team-memberships)
* View all active team members and their roles.
* Invite new colleagues with **Developer** or **Viewer** access via secure email magic links.
* Remove team members upon offboarding.
## Self-Serve Sandbox Mode
[Section titled “Self-Serve Sandbox Mode”](#self-serve-sandbox-mode)
For testing integrations without requesting manual account provisioning:
* **Instant Sandbox Signup**: Visit `/sandbox/signup` to immediately generate a test merchant, secret test key (`sk_test_...`), and webhook secret (`whsec_...`).
* **Production Isolation**: When `TEST_KEYS_ENABLED=false` on production deployments, test mode operations return `403 forbidden`. The dashboard automatically detects this configuration and routes developers to the dedicated Sandbox environment.
# Errors
> Error format and every error code the API returns, with what to do about each.
Every error has the same shape. Branch on `code` — it’s stable; `message` is for humans and may change.
```json
{ "error": { "code": "insufficient_funds", "message": "Available balance is too low for this payout", "details": null } }
```
| HTTP | `code` | Meaning | What to do |
| ---- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 400 | `validation_error` | body/query invalid (`details` lists fields), amount below the minimum, unknown `starting_after` | fix the request; don’t retry as-is |
| 400 | `invalid_request` | the request couldn’t be read (e.g. malformed JSON) | fix the request |
| 401 | `authentication_required` | no `Authorization: Bearer sk_…` header | send your secret key |
| 401 | `invalid_api_key` | key malformed, unknown or revoked | check the key and its mode |
| 403 | `forbidden` | account suspended, or `simulate` used with a live key | contact Xaalis / use a test key |
| 403 | `merchant_not_active` | live request while your account isn’t `active` yet | use test mode until activated |
| 404 | `not_found` | no such object **for this key’s merchant and mode** | check id and test/live key |
| 409 | `idempotency_conflict` | `Idempotency-Key` reused with different parameters (amount, provider, recipient…) | use a new key for a different request |
| 409 | `invalid_state` | the object can’t do that now (payment already final or expired, subscription no longer active, a test advance already running) | read the object’s state; create a new one if needed |
| 409 | `key_secret_unavailable` | a key issuance (API key or agent-wallet key) was retried: a secret is only ever shown once | revoke the key id in the error, then issue a replacement with a **new `Idempotency-Key`** |
| 422 | `insufficient_funds` | payout larger than `available` (fee included) | pay out less |
| 422 | `unsupported` | e.g. Orange Money payouts in live mode | use Wave |
| 429 | `rate_limited` | more than 100 requests/second on one key (60/s per IP on checkout routes) | wait `Retry-After` seconds, then retry |
| 502 | `provider_error` | Wave / Orange Money failed to start the payment | retry, or offer the other method |
| 503 | `temporarily_unavailable` | a dependency (ledger, database) timed out or is briefly unavailable — the request **may already have taken effect** — or a payout/refund outcome isn’t known yet | wait `Retry-After` seconds, then retry with the **same** `Idempotency-Key` (a replay never applies it twice) |
| 503 | `provider_not_configured` | that provider isn’t enabled on this platform | offer the other method |
| 500 | `internal_error` | our bug; `request_id` is included | retry with the same `Idempotency-Key`; report the `request_id` |
Retry only `429` (after `Retry-After`), `5xx` and network errors — and only with the same `Idempotency-Key`. Never retry any other `4xx` unchanged.
# Example: a complete shop
> A complete, tested reference integration — a small Dakar shop taking Wave and Orange Money through Xaalis.
`apps/demo-shop` is a small shop (“Boutique Démo Dakar”) that integrates Xaalis the way every merchant should. It’s about 250 lines of TypeScript with the [TypeScript SDK](/guides/sdk-node/), and its tests run against a real Xaalis stack.
```bash
yarn workspace @xaalis/demo-shop setup # creates a test merchant and points its webhook at the shop
yarn workspace @xaalis/demo-shop dev # http://localhost:4200
```
## The three moments of a payment
[Section titled “The three moments of a payment”](#the-three-moments-of-a-payment)
**1. Checkout — create the payment for exactly the order total.**
```ts
const payment = await xaalis.payments.create(
{
amount: order.total, // integer XOF
client_reference: order.id,
success_url: `${baseUrl}/orders/${order.id}`,
cancel_url: `${baseUrl}/orders/${order.id}`,
metadata: { order_id: order.id },
},
// A double click or retry can't create a second payment.
{ idempotencyKey: `${testMode ? "test" : "live"}:order:${order.id}` },
);
return redirect(payment.checkout_url);
```
**2. The customer comes back — show, don’t trust.** Landing on `success_url` proves nothing. The page shows “Confirmation en cours…” and asks Xaalis from the server:
```ts
const p = await xaalis.payments.retrieve(order.paymentId); // server to server: this *can* be trusted
if (p.status === "succeeded" && p.amount === order.total) markPaid(order);
```
**3. The webhook — the source of truth.**
```ts
const event = constructEvent(rawBody, req.header("xaalis-signature"), webhookSecret); // throws on a bad signature
if (!rememberEvent(event.id)) return ok(); // at-least-once delivery: ignore repeats
if (event.livemode !== isLive) return ok(); // test and live share one URL
const p = event.data.object;
if (p.id === order.paymentId && p.amount === order.total && p.currency === "XOF") markPaid(order); // once
```
`markPaid` is idempotent: an order already paid is never paid again, whether the webhook or the return page gets there first.
## What its tests prove
[Section titled “What its tests prove”](#what-its-tests-prove)
| Scenario | Outcome |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Checkout | a payment for exactly the order total, redirect to the hosted checkout |
| Customer lands on the return page before paying | order stays unpaid |
| Customer pays | the signed webhook marks the order paid, exactly once |
| Payment declined | order marked failed |
| Forged signature · replayed event · signed event with the wrong amount · live event on a test shop | rejected or ignored; order unchanged |
| Webhook lost | the return page’s server-side check still completes the order |
The same rules, in PHP for WordPress, are in the [WooCommerce plugin](/guides/woocommerce/).
# FAQ
> Short answers to the questions merchants ask most.
**Which payment methods do customers see?** Wave and Orange Money (via the Max It app or Orange Money QR code), as far as they’re enabled on the platform.
**Do amounts have decimals?** No. XOF has no subunit: `15000` means 15 000 F CFA. Floats are rejected.
**How much does Xaalis charge?** By default 1.5 % per successful payment, rounded up to the franc; your contract may differ. The exact `fee` and `net` are on every payment object. See [Payments → Fees](/guides/payments/#fees).
**When is money available?** As soon as a payment is `succeeded`, its `net` is in `available` and can be paid out.
**How do I get my money?** With a [payout](/guides/payouts/) to a Wave wallet (Orange Money payouts are coming). Automatic daily settlement is planned.
**The customer says they paid but the order isn’t marked paid.** Check `GET /v1/payments/{id}`. If it’s `processing`, the provider hasn’t confirmed yet — wait for the webhook. If it’s `succeeded`, check `GET /v1/webhook-deliveries` for a delivery your server refused.
**The customer was redirected to my success page — can I ship?** No. Only a verified `payment.succeeded` webhook or a server-side `GET` proves payment. See [Payments](/guides/payments/#never-trust-the-redirect).
**Can I refund a payment?** Yes, in full, for Wave payments: `POST /v1/payments/{id}/refunds`. See [Refunds](/guides/refunds/).
**Can I bill every month, or sell in instalments?** Yes — [Subscriptions and instalments](/guides/subscriptions/). Each period the customer approves one payment from a link you send.
**How long is a checkout link valid?** 30 minutes. After that the payment becomes `expired`; create a new one.
**How do I test without real money?** Use your `sk_test_` key: payments go to a test wallet where you choose the outcome. See [Test mode](/guides/test-mode/).
**I got `403 merchant_not_active`.** Your account hasn’t been activated for live payments yet. Keep integrating in test mode and contact Xaalis.
# Go-live checklist
> Everything to check before your first live payment.
Work through this list before switching your shop to a `sk_live_` key. Each line links to the detail.
## Your Xaalis account
[Section titled “Your Xaalis account”](#your-xaalis-account)
* [ ] Your account is **`active`**. Until then, live requests return `403 merchant_not_active` — ask Xaalis to finish the review.
* [ ] You have a **live secret key** (`sk_live_…`), stored in your server’s secret store, **never** in browser code, a mobile app, a Git repository or a log.
* [ ] Your **webhook secret** is stored the same way.
## Your integration
[Section titled “Your integration”](#your-integration)
* [ ] Every `POST /v1/payments` and `POST /v1/payouts` (required there) sends an **`Idempotency-Key`** tied to your order or withdrawal. → [Idempotency](/guides/idempotency/)
* [ ] You fulfil orders only on the signed **`payment.succeeded` webhook** (or a server-side `GET /v1/payments/{id}`), never because the customer reached `success_url`. → [Payments](/guides/payments/#never-trust-the-redirect)
* [ ] Your webhook endpoint is **`https://`**, verifies `Xaalis-Signature` over the **raw body**, rejects timestamps older than 5 minutes, answers **2xx within 10 s**, and **deduplicates on event `id`**. → [Webhooks](/guides/webhooks/)
* [ ] It checks **`livemode: true`** before shipping — test and live events share one URL.
* [ ] You compare the webhook’s `amount` and `client_reference` with your order before fulfilling.
* [ ] Amounts are **whole XOF** everywhere in your code (no `1500.00`, no floats).
* [ ] Errors are handled by `code` (`insufficient_funds`, `idempotency_conflict`…), not by message. → [Errors](/guides/errors/)
* [ ] `success_url` and `cancel_url` are `https://` pages on your own site.
## Test it in test mode first
[Section titled “Test it in test mode first”](#test-it-in-test-mode-first)
* [ ] A payment that **succeeds**, one that **fails** and one that **expires** each reach the right order state.
* [ ] A **duplicate** webhook (replay the same event) doesn’t ship twice.
* [ ] A webhook with a **wrong signature** is rejected with 400.
* [ ] A payout that **fails** (recipient ending in `0000`) is handled. → [Test mode](/guides/test-mode/)
## On the day
[Section titled “On the day”](#on-the-day)
* [ ] Swap `sk_test_` for `sk_live_` **and** the base URL from `https://sandbox-api.xaalis.org` to `https://api.xaalis.org` (and your webhook secret for the production account’s) in your server configuration.
* [ ] Make one small real payment yourself (e.g. 100 F CFA) with Wave and one with Orange Money, and watch the webhook arrive.
* [ ] Watch `GET /v1/webhook-deliveries` for failures during the first days.
# Idempotency
> Make retries safe — how Idempotency-Key works on payments and payouts.
Networks fail. When a request times out you don’t know whether it reached Xaalis. **Idempotency keys make the retry safe**: send the same `Idempotency-Key` header and you get the original object back instead of a second payment or a second payout.
```http
POST /v1/payments
Idempotency-Key: order-1042
```
* **Required** on `POST /v1/payouts`, refunds, subscription create/cancel/advance and agent-wallet writes (a request without it gets `400`); strongly recommended on `POST /v1/payments`.
* Keys are scoped to your merchant account; up to you to make them unique — your order or withdrawal id works well.
* **Same key, same request** → the original object (even under concurrent retries for payments).
* **Same key, different parameters** → `409 idempotency_conflict` (a payment’s amount; a payout’s amount, provider or recipient). Nothing is created.
* Reuse a key only for a *retry of the same operation*. A new payment needs a new key.
The [Node SDK](/guides/sdk-node/) makes you pass the key on every create, so it stays the same across retries *and* process restarts — use your own order or withdrawal id.
Keys are scoped to your merchant **and the mode**: the same key used with a test key and with a live key creates two independent objects.
# Payments
> Payment lifecycle, statuses, hosted checkout, fees, expiry and what never to trust.
A **payment** is one request for money from one customer. You create it on your server, the customer pays on the hosted checkout with Wave or Orange Money, and Xaalis tells you the result by webhook.
```
stateDiagram-v2
[*] --> requires_payment_method: POST /v1/payments
requires_payment_method --> processing: customer picks Wave / Orange Money
requires_payment_method --> expired: 30 min, nobody paid
processing --> succeeded: provider confirms
processing --> failed: declined / cancelled / amount mismatch
succeeded --> [*]
failed --> [*]
expired --> [*]
```
`succeeded`, `failed` and `expired` are **final**. A succeeded payment can later become `refunded` through a [refund](/guides/refunds/) — nothing else changes a final payment. A failed payment can’t be retried: create a new one (use a new `Idempotency-Key`).
## Create
[Section titled “Create”](#create)
`POST /v1/payments` with a secret key. Send an [`Idempotency-Key`](/guides/idempotency/) so retries can’t create two payments.
| Field | Required | Notes |
| --------------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `amount` | yes | integer XOF, **100** to **100 000 000**. Floats, strings and `0` are rejected (`400`). |
| `currency` | no | only `"XOF"` (the default) |
| `description` | no | ≤ 200 chars, shown to the customer on the checkout |
| `client_reference` | no | your order id (≤ 100), returned on the payment and in webhooks |
| `success_url`, `cancel_url` | no | where the checkout sends the customer after a final state — use `https://` |
| `customer` | no | `{ phone: "+2217XXXXXXXX", name }` |
| `metadata` | no | string → string, keys ≤ 40, values ≤ 500 chars. Never shown to the customer. |
| `provider` | no | `"wave"` or `"orange_money"`: skip the method picker; `next_action` is ready in the response |
The response is the [payment object](#the-payment-object) with `status: "requires_payment_method"` and a `checkout_url`. Redirect the customer there.
## The hosted checkout
[Section titled “The hosted checkout”](#the-hosted-checkout)
`checkout_url` carries a client secret (`cs=…`) that lets the browser see **only** what the customer needs: amount, description, your business name, status. It never exposes your fee, `metadata`, `client_reference` or ids. Treat the URL like a password: don’t log it or put it in analytics.
When the customer picks a method:
* **Wave** → `next_action: { type: "redirect", url }` — the checkout sends them to Wave. Customers can optionally opt-in to receive their payment receipt and order tracking via WhatsApp (`receipt_opt_in: true`).
* **Orange Money** → `next_action: { type: "qr_code", qrCodeBase64, deeplinks }` — a QR code for Max It / Orange Money and app deep links on mobile.
If you set `provider` at creation you can use `next_action` yourself instead of the hosted checkout.
### Switching payment methods
[Section titled “Switching payment methods”](#switching-payment-methods)
If a customer selects Wave and subsequently wishes to change payment methods, the hosted checkout allows switching to Orange Money once the initial Wave session has expired. To eliminate duplicate debit risks, switching away from an active Orange Money QR session is prohibited by the backend (`409 invalid_state`).
## Never trust the redirect
[Section titled “Never trust the redirect”](#never-trust-the-redirect)
After paying, the customer comes back to your `success_url`. **That proves nothing** — anyone can open that URL. Fulfil only on:
1. the signed [`payment.succeeded` webhook](/guides/webhooks/), or
2. `GET /v1/payments/{id}` returning `status: "succeeded"` (call it from your server).
## Fees
[Section titled “Fees”](#fees)
`fee = ceil(amount × fee_bps / 10 000) + fee_fixed`, capped at `amount`. Default 150 bps (1.5 %), no fixed part; your contract may differ. `net = amount − fee` is what lands in `available`.
| amount | fee at 1.5 % | net |
| ------ | ------------ | ------ |
| 100 | 2 | 98 |
| 1 001 | 16 | 985 |
| 15 000 | 225 | 14 775 |
## Retrieve and list
[Section titled “Retrieve and list”](#retrieve-and-list)
* `GET /v1/payments/{id}`
* `GET /v1/payments?limit=20&status=succeeded&starting_after=pay_…` — newest first, `limit` 1–100 (default 20), `has_more` tells you whether to fetch the next page with the last id as `starting_after`.
## The payment object
[Section titled “The payment object”](#the-payment-object)
| Field | Meaning |
| --------------------------------------------------------- | --------------------------------------------------------------------------- |
| `id` | `pay_…` |
| `livemode` | `false` for test keys |
| `amount`, `fee`, `net`, `currency` | integers in XOF |
| `status` | see the diagram above |
| `provider` | `wave`, `orange_money`, `mock` (test) or `null` before the customer chooses |
| `checkout_url`, `next_action` | where the customer pays |
| `client_reference`, `description`, `customer`, `metadata` | what you sent |
| `failure_reason` | e.g. `declined_or_cancelled`, `amount_mismatch`, `expired` |
| `provider_transaction_id` | the Wave / Orange Money transaction id, once paid |
| `expires_at`, `succeeded_at`, `created_at` | ISO 8601 |
Note
If the provider reports a paid amount that differs from `amount`, the payment is **not** credited: it becomes `failed` with `failure_reason: "amount_mismatch"` and Xaalis staff are alerted. **No webhook is sent in that case today** (tracked as B-10) — if an order stays pending, check `GET /v1/payments/{id}`.
# Payouts
> Withdraw your balance to a Wave or Orange Money wallet — reservation, statuses, fees and failures.
A **payout** sends money from your `available` balance to a mobile-money wallet.
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/payouts \
-H "Authorization: Bearer $XAALIS_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: payout-2026-09-25" \
-d '{"amount":5000,"provider":"wave","recipient":{"phone":"+221770000001","name":"Awa Ndiaye"}}'
```
| Field | Notes |
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `amount` | integer XOF |
| `provider` | `wave` or `orange_money` (Orange Money payouts are **not available in live mode yet** → `422 unsupported`) |
| `recipient.phone` | `+2217XXXXXXXX` |
| `recipient.name` | optional |
| `client_reference` | optional, your id |
## How your balance moves
[Section titled “How your balance moves”](#how-your-balance-moves)
The money is **reserved before** anything is sent, so two payouts can never spend the same francs:
| Step | `available` | `pending_payouts` |
| --------------------------------------- | ---------------- | ----------------- |
| `POST /v1/payouts` accepted (`pending`) | − (amount + fee) | + amount |
| provider confirms → `succeeded` | — | − amount |
| provider refuses → `failed` | + (amount + fee) | − amount |
If `available` can’t cover `amount + fee`, the request fails with **`422 insufficient_funds`** and your balance doesn’t change. The attempt is recorded as a payout with `status: "failed"`, `failure_reason: "insufficient_funds"`, and retrying the same key returns the same `422`. The payout `fee` is shown on the object (0 by default).
## Statuses
[Section titled “Statuses”](#statuses)
`pending` → `processing` → `succeeded` | `failed`. You get `payout.succeeded` / `payout.failed` [webhooks](/guides/webhooks/). Poll `GET /v1/payouts/{id}` if you need to wait synchronously. List with `GET /v1/payouts?limit=&starting_after=`.
An [`Idempotency-Key`](/guides/idempotency/) is **required** (`400` without one): a retried payout request returns the first payout — never a second transfer. The same key with a different amount, provider or recipient returns `409`.
If the API answers **`503 temporarily_unavailable`**, the reservation’s outcome isn’t known yet: retry with the **same** key — you get the original payout or a clean refusal, never two.
# Quickstart
> Create, pay and confirm a test payment in five minutes — no Wave or Orange Money account needed.
You need a **test secret key** (`sk_test_…`). Locally, `yarn bootstrap` prints one for the demo merchant; an operator can issue more from the operator console. Keys are shown once — store them server-side.
## 1. Create a payment (your server)
[Section titled “1. Create a payment (your server)”](#1-create-a-payment-your-server)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/payments \
-H "Authorization: Bearer $XAALIS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{"amount":15000,"description":"Commande #1042","client_reference":"order_1042","success_url":"https://shop.example/merci"}'
```
```json
{
"id": "pay_…", "object": "payment", "livemode": false,
"amount": 15000, "currency": "XOF", "fee": 225, "net": 14775,
"status": "requires_payment_method",
"checkout_url": "http://localhost:3000/pay/pay_…?cs=pay_…_secret_…",
"expires_at": "…"
}
```
`fee` is your Xaalis fee (1.5 % by default, rounded **up** to the franc); `net` is what reaches your balance.
## 2. Send the customer to `checkout_url`
[Section titled “2. Send the customer to checkout\_url”](#2-send-the-customer-to-checkout_url)
The hosted checkout lets them choose Wave or Orange Money. In test mode both lead to a **test wallet** page with *Approuver* / *Refuser* — no real money moves.
From a script you can play the customer instead:
```bash
curl -s -X POST "https://sandbox-api.xaalis.org/v1/payments/$PAYMENT_ID/simulate" \
-H "Authorization: Bearer $XAALIS_KEY" -H "Content-Type: application/json" \
-d '{"outcome":"succeeded"}'
```
## 3. Confirm — with a webhook, not the redirect
[Section titled “3. Confirm — with a webhook, not the redirect”](#3-confirm--with-a-webhook-not-the-redirect)
A customer landing on your `success_url` proves nothing (they can type that URL). Fulfil the order when you receive the signed `payment.succeeded` [webhook](/guides/webhooks/), or when `GET /v1/payments/{id}` says `succeeded`.
## 4. Check your balance
[Section titled “4. Check your balance”](#4-check-your-balance)
```bash
curl -s https://sandbox-api.xaalis.org/v1/balance -H "Authorization: Bearer $XAALIS_KEY"
# {"object":"balance","livemode":false,"currency":"XOF","available":14775,"pending_payouts":0}
```
Next: [Payments](/guides/payments/) for the full lifecycle, or the [Node SDK](/guides/sdk-node/) to skip the curl.
# Refunds
> Give a customer their money back — full refunds of Wave payments, fees, and what happens when the outcome is uncertain.
A refund sends the **full** amount of a succeeded payment back to the customer’s wallet. There are no partial refunds.
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/payments/$PAYMENT_ID/refunds \
-H "Authorization: Bearer $XAALIS_KEY" \
-H "Idempotency-Key: refund:order_1042"
```
```json
{ "id": "re_…", "object": "refund", "payment_id": "pay_…", "amount": 15000, "currency": "XOF",
"status": "succeeded", "fee_refunded": 0, "created_at": "…" }
```
* **`Idempotency-Key` is required.** Repeating the request returns the same refund — a payment can never be refunded twice, however many times you retry.
* Only **succeeded Wave payments** can be refunded (test mode simulates Wave).
* `GET /v1/payments/{id}/refunds` returns the refund (a list of zero or one) — use it to check an outcome without retrying.
## Who pays the fee
[Section titled “Who pays the fee”](#who-pays-the-fee)
The customer always gets the **gross** amount back. By default Xaalis **keeps its fee**, so your `available` balance must cover the gross amount:
| Payment | Your fee | Refund debits your balance | Customer receives |
| ------- | -------- | -------------------------- | ----------------- |
| 15 000 | 225 | 15 000 | 15 000 |
`fee_refunded` shows the fee Xaalis returned (0 under the default policy). If your balance can’t cover it, the request fails with **`422 insufficient_funds`** and nothing happens; retry once funds arrive.
## Statuses
[Section titled “Statuses”](#statuses)
| `status` | Meaning | You do |
| -------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ |
| `succeeded` | the money is back with the customer; the payment is now `refunded` | nothing — you also get `payment.refunded` |
| `pending` | the provider’s answer isn’t known yet (the API may have returned `503`) | wait for `payment.refunded`, or retry with the **same** key |
| `needs_review` | the provider refused; the amount stays reserved from your balance | contact Xaalis — an operator reconciles it with the provider |
A refunded subscription invoice stays paid; refunding doesn’t restart its billing cycle.
# Node / TypeScript SDK
> @xaalis/sdk — the TypeScript client for Node servers: payments, payouts, agent wallets, webhook verification, safe retries.
`@xaalis/sdk` is the server-side TypeScript client (Node 20+, no runtime dependencies). Not on npm yet — use it from the monorepo.
```ts
import { XaalisClient, XaalisApiError, constructEvent, formatXof } from "@xaalis/sdk";
const xaalis = new XaalisClient({ secretKey: process.env.XAALIS_SECRET_KEY!, baseUrl: "https://api.xaalis.org" });
const payment = await xaalis.payments.create(
{ amount: 15000, description: "Commande #1042", client_reference: "order_1042", success_url: "https://shop.example/merci" },
{ idempotencyKey: "payment:order_1042" }, // required, and the same on every retry
);
console.log(formatXof(payment.amount), payment.checkout_url);
```
| API | Methods |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `payments` | `create`, `retrieve`, `list`, `listAll` (async iterator over every page) |
| `payouts` | `create`, `retrieve`, `list`, `listAll` |
| `payments.refunds` | `create(paymentId, { idempotencyKey })`, `list(paymentId)` — see [Refunds](/guides/refunds/) |
| `subscriptions` | `create`, `retrieve`, `list`, `listAll`, `cancel`, `testAdvance` (test keys) — see [Subscriptions](/guides/subscriptions/) |
| `invoices` | `retrieve`, `list`, `listAll` (filters `subscription`, `status`) |
| `balance` | `retrieve` |
| `agentWallets` | create, fund, reclaim, disable, keys, payouts — see [Agent wallets](/guides/agent-wallets/) |
| `testHelpers` | `simulatePayment(id, "succeeded" \| "failed")` — test keys only |
| `constructEvent(raw, header, secret)` | verify and parse a [webhook](/guides/webhooks/) |
```ts
const refund = await xaalis.payments.refunds.create(payment.id, { idempotencyKey: `refund:${payment.id}` });
const sub = await xaalis.subscriptions.create(
{ customer: { phone: "+221771234567", name: "Awa Ndiaye" }, amount: 5_000, interval: "month" },
{ idempotencyKey: "subscription:customer-123:monthly" },
);
// Send sub.latest_invoice?.hosted_invoice_url to the customer (it stays valid across payment attempts).
```
## What it does for you
[Section titled “What it does for you”](#what-it-does-for-you)
* **Idempotency is explicit:** every create takes an `idempotencyKey` you choose (your order or withdrawal id) — it stays the same across retries *and* process restarts.
* **Retries only when safe:** reads on network errors, `429` and `5xx`, and any request answered `429`, honouring `Retry-After` (`maxRetries`, default 2). If a write’s outcome is unknown (`error.outcomeUnknown`), retry it yourself with the **same** key.
* **Errors:** `XaalisApiError` (`status`, `code`, `message`), `XaalisNetworkError` / `XaalisProtocolError` (`outcomeUnknown`), `XaalisSignatureError`.
* **Safety:** refuses to run in a browser and refuses a live key over `http://`.
```ts
try {
await xaalis.payouts.create({ amount: 5000, provider: "wave", recipient: { phone: "+221770000001" } }, { idempotencyKey: "withdraw:2026-09-27" });
} catch (err) {
if (err instanceof XaalisApiError && err.code === "insufficient_funds") {
/* show "balance too low" */
} else throw err;
}
```
A complete integration using it: [Example shop](/guides/example-shop/). Full reference: `packages/sdk/README.md`.
# PHP SDK
> xaalis-php — PHP client: idempotent creates, safe retries, typed errors, webhook verification.
`xaalis-php` (in `integrations/sdk-php`) wraps the API for PHP 8.1+ servers. No runtime dependencies beyond `ext-curl` and `ext-json`. Not on Packagist yet: use it from the monorepo, through Composer’s PSR-4 autoload (`Xaalis\` → `src/`) or the bundled `autoload.php`.
```php
require __DIR__ . '/vendor/autoload.php';
$xaalis = new Xaalis\Client(getenv('XAALIS_SECRET_KEY'), ['base_url' => 'https://sandbox-api.xaalis.org']);
$payment = $xaalis->payments->create(
[
'amount' => 15000, // int, XOF — never a float or a string
'description' => 'Commande #1042',
'client_reference' => 'order_1042',
'success_url' => 'https://shop.example/merci',
'metadata' => ['order_id' => '1042'],
],
['idempotency_key' => 'payment:order_1042'],
);
header('Location: ' . $payment['checkout_url']); // hosted checkout
```
Responses are associative arrays with the same fields as the API ([payment object](/guides/payments/#the-payment-object)).
| Service | Methods |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| `payments` | `create`, `retrieve`, `list`, `all` (generator over every page) |
| `payouts` | `create`, `retrieve`, `list`, `all` |
| `balance` | `retrieve` |
| `account` | `retrieve`, `update(['webhook_url' => 'https://…'])` |
| `webhookDeliveries` | `list(['limit' => 20])` |
| `testHelpers` | `simulatePayment($id, 'succeeded' \| 'failed')` — test keys only |
| `Xaalis\Webhook::constructEvent($raw, $header, $secret)` | verify and parse a [webhook](/guides/webhooks/) |
## Options
[Section titled “Options”](#options)
| Option | Default | |
| ------------- | ----------------------- | ---------------------------------------------------------------------------------------------------- |
| `base_url` | `https://api.xaalis.sn` | a live key over `http://` is refused |
| `timeout` | `30` | seconds per attempt |
| `max_retries` | `2` | retries after the first attempt |
| `http` | curl | `fn($method, $url, $headers, $body, $timeout)` returning `['status', 'body', 'headers']` — for tests |
The key must match `sk_test_…` / `sk_live_…` (40 characters after the prefix); it never appears in exception messages or in `var_dump($xaalis)`. `$xaalis->testMode` is `true` for test keys.
## What it does for you
[Section titled “What it does for you”](#what-it-does-for-you)
* **Idempotency:** every `create` sends an `Idempotency-Key` — a random UUID v4 unless you pass `idempotency_key` — and **reuses it on every retry**. Pass your own (your order id) to stay safe across process restarts too. See [Idempotency](/guides/idempotency/).
* **Retries:** network errors, `429` and `5xx`, with exponential backoff and `Retry-After` — only when replaying is safe: `GET`, `PATCH`, or a create carrying its key. `4xx` is never retried.
* **Integers only:** `amount` must be a PHP `int`; `150.5`, `15000.0` and `"15000"` throw `\InvalidArgumentException` before anything is sent.
## Errors
[Section titled “Errors”](#errors)
```php
use Xaalis\Exception\ApiException;
use Xaalis\Exception\ConnectionException;
try {
$xaalis->payouts->create(['amount' => 5000, 'provider' => 'wave', 'recipient' => ['phone' => '+221770000001']]);
} catch (ApiException $e) {
if ($e->getErrorCode() === 'insufficient_funds') {
// show "balance too low"
} else {
throw $e;
}
} catch (ConnectionException $e) {
// unknown outcome: retry later with the SAME idempotency_key
}
```
| Exception | When |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Xaalis\Exception\XaalisException` | base class of the three below |
| `ApiException` | the API answered an error: `getStatus()`, `getErrorCode()` (stable, see [Errors](/guides/errors/)), `getMessage()`, `getDetails()` |
| `ConnectionException` | network failure or timeout after the retries |
| `SignatureException` | a webhook failed verification — answer `400` |
## Webhooks
[Section titled “Webhooks”](#webhooks)
```php
$raw = file_get_contents('php://input'); // the exact bytes, never a re-encoded array
try {
$event = Xaalis\Webhook::constructEvent($raw, $_SERVER['HTTP_XAALIS_SIGNATURE'] ?? null, getenv('XAALIS_WEBHOOK_SECRET'));
} catch (Xaalis\Exception\SignatureException $e) {
http_response_code(400);
exit;
}
if ($event['livemode'] !== $expectLive || alreadyProcessed($event['id'])) { // test + live share one URL; at-least-once
http_response_code(200);
exit;
}
if ($event['type'] === 'payment.succeeded') {
$payment = $event['data']['object'];
// check $payment['amount'] and $payment['metadata'] against your order, then fulfil it once
}
http_response_code(200);
```
`constructEvent` rejects a missing or malformed header, a timestamp more than 300 s away from now (replays), and a signature that doesn’t match any `v1` (compared with `hash_equals`).
## Tests
[Section titled “Tests”](#tests)
`integrations/sdk-php/tests/docker-test.sh` runs `php -l` and the test suite in `php:8.3-cli`. With `ADMIN_TOKEN` in the repo-root `.env` it also runs a live flow against the local API: create a merchant, a 15 000 payment (fee 225), simulate success, check the balance is 14 775, and check a 99 000 000 payout fails with `insufficient_funds`.
For WooCommerce stores, use the [WooCommerce plugin](/guides/woocommerce/) instead — it doesn’t need Composer.
# Python SDK
> xaalis — Python client, standard library only: idempotent creates, safe retries, webhook verification.
`xaalis` (in `integrations/sdk-python`) wraps the API for Python 3.9+ servers. Standard library only — no `requests`, no `httpx` — and fully type-hinted. Not on PyPI yet: `pip install ./integrations/sdk-python`.
```python
import os
from xaalis import Xaalis, XaalisAPIError, construct_event, format_xof
xaalis = Xaalis(os.environ["XAALIS_SECRET_KEY"], base_url="https://sandbox-api.xaalis.org")
payment = xaalis.payments.create(
amount=15000,
description="Commande #1042",
client_reference="order_1042",
success_url="https://shop.example/merci",
idempotency_key="payment:order_1042",
)
print(format_xof(payment["amount"]), payment["checkout_url"])
```
Objects are plain `dict`s typed as `TypedDict`s (`Payment`, `Payout`, `Balance`, `Account`, `WebhookDelivery`, `WebhookEvent`). Every amount is an `int` in XOF.
| API | Methods |
| -------------------------------------- | -------------------------------------------------------------------- |
| `payments` | `create`, `retrieve`, `list`, `list_all` (generator over every page) |
| `payouts` | `create`, `retrieve`, `list`, `list_all` |
| `balance` | `retrieve` |
| `account` | `retrieve`, `update(webhook_url=...)` |
| `webhook_deliveries` | `list(limit=...)` |
| `test_helpers` | `simulate_payment(id, "succeeded" \| "failed")` — test keys only |
| `construct_event(raw, header, secret)` | verify and parse a [webhook](/guides/webhooks/) |
## What it does for you
[Section titled “What it does for you”](#what-it-does-for-you)
* **Idempotency:** every `create` sends an `Idempotency-Key` (random unless you pass `idempotency_key=`) and **reuses it on each retry**. See [Idempotency](/guides/idempotency/).
* **Retries:** network errors, `429` and `5xx`, with exponential backoff and `Retry-After` — only when replaying is safe (reads, `PATCH`, or creates carrying their key). Default 2 retries, 30 s timeout per attempt. Redirects are never followed.
* **Errors:** `XaalisAPIError` (`status`, `code`, `message`, `details`), `XaalisConnectionError`, `XaalisSignatureError` — all subclasses of `XaalisError`. Codes are listed in [Errors](/guides/errors/).
* **Safety:** refuses a live key over `http://`; rejects non-`int` amounts before sending; never puts the key in an error message or `repr`.
```python
try:
xaalis.payouts.create(amount=5000, provider="wave", recipient={"phone": "+221770000001"})
except XaalisAPIError as err:
if err.code == "insufficient_funds":
... # show "balance too low"
else:
raise
```
## Webhooks
[Section titled “Webhooks”](#webhooks)
Pass the **raw request bytes** — Flask `request.get_data()`, Django `request.body` — never re-serialized JSON:
```python
from xaalis import XaalisSignatureError, construct_event
try:
event = construct_event(request.body, request.headers.get("Xaalis-Signature"), os.environ["XAALIS_WEBHOOK_SECRET"])
except XaalisSignatureError:
return HttpResponse(status=400)
# answer 2xx fast, deduplicate on event["id"], check event["livemode"], then fulfil
```
In [test mode](/guides/test-mode/), `xaalis.test_helpers.simulate_payment(payment["id"], "succeeded")` drives the payment to its final state and fires the same webhook live mode would.
Examples: `integrations/sdk-python/examples/` (checkout, webhook server with `http.server`, payout). Tests: `cd integrations/sdk-python && python3 -m unittest discover -s tests -v`. See also the [Node SDK](/guides/sdk-node/).
# Security best practices
> How to keep your keys, webhooks and customers safe when integrating Xaalis.
## Keys
[Section titled “Keys”](#keys)
* **Secret keys stay on your server.** Anyone with `sk_live_…` can create payouts from your balance. Never put a key in a website, a mobile app, a URL, a support ticket or a Git repository.
* Treat **test keys as secrets too**: they can create test objects and read test data in your account.
* If a key leaks, ask Xaalis for a new one immediately and remove the old one from your systems.
* Load keys from environment variables or a secret manager; keep them out of logs and error trackers.
## Webhooks
[Section titled “Webhooks”](#webhooks)
* Verify **every** request’s `Xaalis-Signature` with your webhook secret before reading it, over the raw body, with a constant-time comparison, and reject timestamps older than 5 minutes. The [Node SDK](/guides/sdk-node/) does all of this.
* Use a hard-to-guess path (`/webhooks/xaalis-7f3a…`) — not a replacement for the signature, but it cuts noise.
* Deduplicate on the event `id`, and re-read the object from the API when the order of events matters.
## Payments and the customer
[Section titled “Payments and the customer”](#payments-and-the-customer)
* Never mark an order paid from the browser or from the redirect to `success_url` — only from your server after a verified webhook or a server-side `GET /v1/payments/{id}`.
* The `checkout_url` contains a per-payment secret (`cs=`). Don’t log it, don’t send it to analytics, and don’t show it to anyone but that customer.
* Don’t put personal data you don’t need in `metadata`.
## Payouts
[Section titled “Payouts”](#payouts)
* Protect the code path that creates payouts as strictly as your admin panel: authenticated staff only, with an audit trail on your side.
* Always send an `Idempotency-Key` so a retried withdrawal can never be sent twice.
* Double-check recipient numbers (`+2217XXXXXXXX`) — a payout that reaches the wrong wallet can’t be pulled back.
# Subscriptions and instalments
> Recurring billing and instalment plans on Wave and Orange Money — one payment link per period, reminders by you, state by Xaalis.
Wave and Orange Money can’t debit a customer automatically. So each billing period, Xaalis issues an **invoice** with a payment link; the customer approves it in their wallet like any payment. Xaalis tracks the state; **you send the link** (by SMS, WhatsApp or email) when you receive `invoice.created`.
The same feature gives **instalment plans** (*paiement en plusieurs fois*): a fixed number of payments, goods delivered after the last one. No credit is involved.
## Create
[Section titled “Create”](#create)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/subscriptions \
-H "Authorization: Bearer $XAALIS_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: abo:customer_77" \
-d '{"customer":{"phone":"+221771234567","name":"Awa Ndiaye"},"amount":5000,"interval":"month","description":"Abonnement Premium"}'
```
| Field | Rules |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer` | `{ phone: "+2217XXXXXXXX", name, email? }` |
| `amount` | integer XOF, 100 – 100 000 000, per period |
| `interval`, `interval_count` | `day` · `week` · `month` · `year`; count 1–12 (default 1). Monthly billing keeps the day of the month, clamped to short months (31 Jan → 28/29 Feb → 31 Mar), in Dakar time |
| `billing_cycles` | `null` (until canceled) or **2–24** for an instalment plan |
| `days_until_due` | 1–30, default 3 — after this the invoice is *overdue* |
| `cancel_after_days` | 1–60, default 14 — grace after the due date before the subscription is canceled |
| `description`, `client_reference`, `metadata` | as on payments |
The response is the subscription with its first invoice (`latest_invoice`) already issued.
## The invoice link
[Section titled “The invoice link”](#the-invoice-link)
Each invoice has a **stable** `hosted_invoice_url` (`…/v1/public/invoices/{id}?cs=…`). It redirects the customer to a payment for that period. If an attempt fails or expires, opening the same link creates a new attempt — **the link never changes**, so you can send it once and remind with the same URL. It stays payable until `grace_ends_at`.
Treat it like a password for that customer: don’t log it or send it to analytics.
## Lifecycle
[Section titled “Lifecycle”](#lifecycle)
```
stateDiagram-v2
[*] --> active: POST /v1/subscriptions (invoice 0 issued)
active --> active: invoice paid → next period's invoice
active --> past_due: due_at passed, unpaid (invoice.overdue)
past_due --> active: paid during grace
past_due --> canceled: grace ended unpaid (invoice uncollectible)
active --> canceled: POST …/cancel
active --> completed: last instalment paid
```
* **One invoice per period, never two**, and no new invoice while one is unpaid — a customer is never asked for two periods at once.
* Paying an invoice is an ordinary payment: same fee, same balance credit, same `payment.succeeded` event.
* `cycles_paid` counts paid invoices; an instalment plan becomes `completed` when it reaches `billing_cycles` — **ship on `subscription.completed`**.
## Cancel
[Section titled “Cancel”](#cancel)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/subscriptions/$SUB_ID/cancel \
-H "Authorization: Bearer $XAALIS_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: cancel:$SUB_ID" -d '{"at_period_end": false}'
```
* `at_period_end: false` stops now: an open invoice with no payment in progress is voided and its link stops working. If the customer is paying at that very moment, that payment is allowed to finish (and is credited).
* `at_period_end: true` keeps the current invoice payable through its grace period and issues no next one.
## Webhooks
[Section titled “Webhooks”](#webhooks)
`invoice.created` (carries `hosted_invoice_url` — send it to the customer) · `invoice.paid` · `invoice.overdue` · `subscription.past_due` · `subscription.canceled` · `subscription.completed`. The `payment.*` events of each attempt still fire. See [Webhooks](/guides/webhooks/).
## Read
[Section titled “Read”](#read)
`GET /v1/subscriptions/{id}` · `GET /v1/subscriptions?status=` · `GET /v1/invoices/{id}` · `GET /v1/invoices?subscription=&status=` — cursor pagination like payments.
## Testing
[Section titled “Testing”](#testing)
`POST /v1/subscriptions/{id}/test_advance` (test keys only, `Idempotency-Key` required) moves the subscription’s clock to its **next billing event** — the next period, the due date, or the end of grace — so you can test months in seconds. One advance runs at a time (a concurrent one gets `409 invalid_state`); a canceled or completed subscription can’t be advanced.
## Instalments: tell your customer
[Section titled “Instalments: tell your customer”](#instalments-tell-your-customer)
Before the first instalment, tell the customer what happens to instalments already paid if they stop paying — that’s your policy. Refunds through Xaalis are [full refunds of each payment](/guides/refunds/).
# Test mode
> What sk_test_ keys do, how to simulate outcomes, and how test data is kept away from live money.
Sandbox and production
Test in the **sandbox**: `https://sandbox-api.xaalis.org` (checkout on `sandbox.xaalis.org`), with a sandbox account and its `sk_test_` keys. It is a separate environment with its own accounts and data. Production is `https://api.xaalis.org` with `sk_live_` keys. Once production is live it refuses test keys: `sk_test_` (and agent `ak_test_`) keys get `401 invalid_api_key` with a message pointing to the sandbox, and test-only routes (simulation, wallet exchange) answer `403 forbidden`.
Every merchant gets two kinds of secret key. The key alone decides the mode — there is no flag to pass.
| | `sk_test_…` | `sk_live_…` |
| --------------------- | ------------------------------------------------------- | ----------------------------------------------- |
| Providers | a **mock** connector — never calls Wave or Orange Money | Wave, Orange Money |
| Money | none; balances live in separate `test:` ledger accounts | real XOF |
| Merchant status | works while your account is `pending_review` | needs `active` (else `403 merchant_not_active`) |
| `livemode` on objects | `false` | `true` |
Test and live data never mix: a test payment is `404` for your live key and the other way round, and test money can’t be paid out to a real wallet.
## Create a sandbox account from code
[Section titled “Create a sandbox account from code”](#create-a-sandbox-account-from-code)
No human needed: the sandbox API opens a test-only account and returns its keys once.
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/sandbox/signups \
-H "Content-Type: application/json" \
-d '{ "email": "dev@example.com", "business_name": "Boutique Awa", "webhook_url": "https://example.com/webhooks/xaalis" }'
```
```json
{ "object": "sandbox_signup",
"merchant": { "id": "mer_…", "object": "merchant", "name": "Boutique Awa", "email": "dev@example.com", "status": "pending_review" },
"secret_key": "sk_test_…", "webhook_secret": "whsec_…" }
```
* The account has a **test key only** — it can never move real money. Going live needs a human (identity checks, approval) in the dashboard.
* Both secrets are shown **once**: store them in environment variables straight away. With an `Idempotency-Key`, a replay returns `409 secret_unavailable` (with `details.merchant_id`) — the secrets are never shown twice.
* Limits: 5 signups per hour and 20 per day per address, 3 per email (`429 rate_limited`, honour `Retry-After`).
* `webhook_url` is optional and must be `https://`. Production (`api.xaalis.org`) doesn’t have this route (`404`).
* AI agents do the same with the MCP tool `xaalis_sandbox_signup` ([AI agents](/guides/ai-agents/)).
## Simulating outcomes
[Section titled “Simulating outcomes”](#simulating-outcomes)
| To get | Do |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| a successful payment | `POST /v1/payments/{id}/simulate` `{"outcome":"succeeded"}` — or click *Approuver* on the test wallet page |
| a failed payment | same with `"failed"` — or *Refuser* |
| an expired payment | leave it unpaid; checkouts expire after **30 minutes** and are closed by a background job within \~5 minutes after that |
| a successful payout | any recipient phone, e.g. `+221770000001` |
| a failed payout | a recipient phone ending in **`0000`**, e.g. `+221770000000` — the reserved amount and fee are returned to `available` |
| `insufficient_funds` | a payout larger than `available` |
`simulate` goes through the same path as a real provider notification (inbox → state machine → ledger → webhook), so what you see in test mode is what live mode does. It returns `403` with a live key.
One webhook URL for both modes
Your account has **one** `webhook_url`, used for both test and live events — always check `livemode` in the payload before fulfilling. Only a **live** key can change that URL, and each key sees only its own mode’s deliveries.
# Wallet exchange
> Send Orange Money, receive Wave (or the reverse) on the same phone — quotes, fees, limits, statuses and how to test it. Test mode only.
A person pays **X F CFA from one wallet** and receives **X − fee on their other wallet**, on the **same phone number**: Orange Money → Wave, or Wave → Orange Money. Same currency, no conversion: Xaalis collects on one rail and pays out on the other.
Test mode only
Exchanges run in the sandbox (`https://sandbox-api.xaalis.org`) with simulated wallets. **Live is locked off** until the licence, the providers’ written approval, verified Orange Money pay-ins/payouts/refunds and anti-money-laundering procedures are in place. Nothing on this page moves real money.
These are **public** endpoints (no API key), like the hosted checkout: they sit behind the Xaalis exchange page. An exchange is protected by its own client secret `cs`, returned once when it is created.
## The flow
[Section titled “The flow”](#the-flow)
```
sequenceDiagram
participant P as Person (exchange page)
participant X as Xaalis API
P->>X: POST /v1/public/exchanges/quotes (from, amount, phone)
X-->>P: quote exq_… (fee, receive_amount, expires in 5 min)
P->>X: POST /v1/public/exchanges/otp (phone)
P->>X: POST /v1/public/exchanges (quote, otp) + Idempotency-Key
X-->>P: exchange exc_… + checkout_url + cs
P->>X: pays on the hosted checkout (source wallet)
X-->>P: payout on the other wallet, same phone
P->>X: GET /v1/public/exchanges/{id}?cs=… until final
```
### 1. Quote
[Section titled “1. Quote”](#1-quote)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges/quotes \
-H "Content-Type: application/json" \
-d '{ "from": "orange_money", "send_amount": 10000, "phone": "+221771234567" }'
```
```json
{ "object": "exchange_quote", "id": "exq_…", "from": "orange_money", "to": "wave",
"send_amount": 10000, "fee": 150, "receive_amount": 9850, "currency": "XOF", "expires_at": "…" }
```
* Give **`send_amount`** (what leaves the source wallet) **or `receive_amount`** (what must arrive). For a receive amount, Xaalis finds the smallest send amount that delivers exactly that; if none does, you get `422 limit_exceeded`.
* `to` is always the other rail; sending the same rail on both sides is refused.
* A quote lasts **5 minutes** and can fund **one** exchange.
### 2. Confirm the phone
[Section titled “2. Confirm the phone”](#2-confirm-the-phone)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges/otp \
-H "Content-Type: application/json" -d '{ "phone": "+221771234567" }'
```
In test mode no SMS is sent: the response carries `"test_code": "000000"`, and **`000000` is the only accepted code**. At most 5 codes per phone per hour (`429 rate_limited` after that).
### 3. Create the exchange
[Section titled “3. Create the exchange”](#3-create-the-exchange)
```bash
curl -s -X POST https://sandbox-api.xaalis.org/v1/public/exchanges \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{ "quote": "exq_…", "otp": "000000" }'
```
```json
{ "object": "exchange", "id": "exc_…", "status": "awaiting_payment", "from": "orange_money", "to": "wave",
"send_amount": 10000, "fee": 150, "receive_amount": 9850, "currency": "XOF", "livemode": false,
"created_at": "…", "checkout_url": "https://sandbox.xaalis.org/…", "cs": "…" }
```
* **`Idempotency-Key` is required**; retrying with the same key returns the same exchange.
* Send the person to `checkout_url` to pay from the source wallet. They have **15 minutes**; after that the exchange is `expired` and nothing moves.
* Keep `cs` server-side or in the page’s memory only — it is the only way to read the exchange.
### 4. Follow it
[Section titled “4. Follow it”](#4-follow-it)
```bash
curl -s "https://sandbox-api.xaalis.org/v1/public/exchanges/exc_…?cs=$CS"
```
Poll while the status isn’t final. The response never contains ledger, merchant or provider details, and is sent with `Cache-Control: no-store`.
| Status | What it means for the person |
| ------------------- | ------------------------------------------------------------------------------------- |
| `awaiting_payment` | waiting for them to pay on the checkout |
| `paid` | payment received; the payout is about to start |
| `payout_processing` | money is on its way to the other wallet |
| `completed` | **final** — `receive_amount` arrived on the other wallet |
| `expired` | **final** — not paid within 15 minutes; nothing was taken |
| `refunding` | the payout failed; the full `send_amount` is being returned |
| `refunded` | **final** — the full `send_amount` went back to the source wallet (the fee too) |
| `needs_review` | a Xaalis operator is checking it — the money is safe and the person will be contacted |
Only a provider-confirmed payment counts. Landing on a “success” page proves nothing.
## Fees
[Section titled “Fees”](#fees)
`fee = max(100, ⌈send_amount × 1.5 %⌉)` F CFA, rounded **up**, in whole francs; `receive_amount = send_amount − fee`.
| Sends | Fee | Receives |
| ------- | ----- | -------- |
| 1 000 | 100 | 900 |
| 10 000 | 150 | 9 850 |
| 33 333 | 500 | 32 833 |
| 200 000 | 3 000 | 197 000 |
If the payout fails, the fee is returned with the rest: a refunded exchange costs the person nothing.
## Limits
[Section titled “Limits”](#limits)
| | Default |
| ---------------------------- | ------------------------------------------------- |
| Per exchange | 1 000 – 200 000 F CFA |
| Per phone, rolling 24 h | 500 000 F CFA |
| A phone’s first 7 days | 50 000 F CFA per exchange, 100 000 F CFA per 24 h |
| Exchanges per phone per hour | 5 |
Over a limit → `422 limit_exceeded` (checked at the quote, and again when the exchange is created). If the destination wallet doesn’t have enough float for the payout → `503 liquidity_unavailable`: try again later or with a smaller amount.
## Same person, both sides
[Section titled “Same person, both sides”](#same-person-both-sides)
The phone that pays is the phone that receives — the payout always goes to the quote’s phone on the other rail. There is no way to send an exchange to someone else.
## Testing
[Section titled “Testing”](#testing)
Drive the pay-in without a wallet, with the exchange’s `cs`:
```bash
curl -s -X POST "https://sandbox-api.xaalis.org/v1/public/exchanges/exc_…/simulate?cs=$CS" \
-H "Content-Type: application/json" -d '{ "outcome": "succeeded" }'
```
`succeeded` runs the whole exchange to `completed`; `failed` declines the pay-in (the exchange never pays out).
## Errors
[Section titled “Errors”](#errors)
| HTTP | `code` | When |
| ---- | ----------------------- | ----------------------------------------------------------------------------------- |
| 400 | `validation_error` | bad field, a phone that isn’t `+2217XXXXXXXX`, a wrong OTP |
| 403 | `forbidden` | called on an API where test mode is off (production) — use `sandbox-api.xaalis.org` |
| 404 | `not_found` | unknown exchange, or wrong `cs` |
| 409 | `invalid_state` | the quote expired or was already used |
| 422 | `limit_exceeded` | amount outside the limits above |
| 429 | `rate_limited` | too many requests or OTP codes — wait `Retry-After` seconds |
| 503 | `liquidity_unavailable` | not enough float on the destination rail right now |
The full schemas are in the [API reference](/api/) under *Wallet exchange*.
# Webhooks
> Signed event notifications — events, payload, signature verification, retries and deduplication.
Xaalis POSTs an event to your `webhook_url` whenever a payment or payout reaches a final state. This is how you learn that money arrived — not the customer’s redirect.
Set the URL with `PATCH /v1/account` `{"webhook_url":"https://…"}` (must be `https://`), or ask an operator.
## Events
[Section titled “Events”](#events)
| `type` | When |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `payment.succeeded` | provider confirmed; `net` has been added to `available` |
| `payment.failed` | declined or cancelled by the customer |
| `payment.expired` | nobody paid within 30 minutes |
| `payout.succeeded` | the money reached the recipient’s wallet |
| `payout.failed` | the provider refused; amount **and fee** are back in `available` |
| `payment.refunded` | a [refund](/guides/refunds/) reached the customer |
| `invoice.created` · `invoice.paid` · `invoice.overdue` | [subscription](/guides/subscriptions/) invoices — `invoice.created` carries the link to send |
| `subscription.past_due` · `subscription.canceled` · `subscription.completed` | subscription state changes; ship instalment plans on `completed` |
```json
{
"id": "whd_…",
"type": "payment.succeeded",
"livemode": true,
"created_at": "2026-09-24T10:42:00.000Z",
"data": { "object": { "id": "pay_…", "object": "payment", "amount": 15000, "fee": 225, "net": 14775, "status": "succeeded", "…": "…" } }
}
```
`data.object` is the same object `GET /v1/payments/{id}` or `GET /v1/payouts/{id}` returns.
## Verify the signature
[Section titled “Verify the signature”](#verify-the-signature)
Every request carries:
```plaintext
Xaalis-Signature: t=1758793320,v1=5f2b…
Xaalis-Event-Id: whd_…
```
`v1` is `HMAC-SHA256(webhook_secret, ".")` in hex. Your `webhook_secret` is given once when the account is created. To verify:
1. Take the **raw request bytes** — not a re-serialized JSON object (key order and spacing change the signature).
2. Reject if `|now − t| > 300` seconds (stops replays).
3. Compute the HMAC and compare with a **constant-time** function against every `v1` in the header.
```ts
import { constructEvent } from "@xaalis/sdk";
// express: app.post("/xaalis", express.raw({ type: "application/json" }), handler)
const event = constructEvent(req.body, req.header("xaalis-signature"), process.env.XAALIS_WEBHOOK_SECRET!);
```
Without the SDK (Node):
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"));
const got = Buffer.from(parts.v1 ?? "");
return got.length === expected.length && timingSafeEqual(got, expected);
}
```
## Respond fast, then work
[Section titled “Respond fast, then work”](#respond-fast-then-work)
Answer **2xx within 10 seconds**, then process. Anything else — a timeout, `3xx` (redirects are not followed), `4xx`, `5xx` — counts as a failure and is retried.
## Retries and duplicates
[Section titled “Retries and duplicates”](#retries-and-duplicates)
* Up to **15 attempts**, exponential backoff starting at 8 s (8 s, 16 s, 32 s … the last one about **36 hours** after the first). After that the delivery is marked `failed`.
* Delivery is **at least once**: the same event can arrive twice. Deduplicate on `id` (also in `Xaalis-Event-Id`), e.g. with a unique column.
* Events can arrive out of order. If order matters, re-read the object with `GET /v1/payments/{id}`.
* Check `livemode` — test and live events go to the same URL.
`GET /v1/webhook-deliveries?limit=20` lists recent deliveries with `status` (`pending`, `succeeded`, `failed`), `attempts`, `last_status_code` and `last_error`, to debug your endpoint.
# WooCommerce
> Accept Wave and Orange Money in a WooCommerce store — install, configure, test, and how orders get paid.
The **Xaalis for WooCommerce** plugin (in `integrations/woocommerce/xaalis-for-woocommerce`) adds a “Wave / Orange Money (Xaalis)” payment method. The customer is redirected to the Xaalis hosted checkout, pays with Wave or Orange Money, and the order is marked paid when Xaalis confirms it.
Requirements: WordPress 6.4+, WooCommerce 8.2+ (tested with 11.1), PHP 8.1+, store currency **CFA franc (XOF)**. Works with the classic checkout and the Cart/Checkout blocks, and with HPOS (High-Performance Order Storage).
## Install and configure
[Section titled “Install and configure”](#install-and-configure)
1. Zip the `xaalis-for-woocommerce` folder, upload it in **Plugins → Add New → Upload**, activate it.
2. **WooCommerce → Settings → General**: currency **CFA franc (XOF)**, number of decimals **0**.
3. **WooCommerce → Settings → Payments → Wave / Orange Money (Xaalis)**:
| Setting | Value |
| --------------- | --------------------------------------------------- |
| Test mode | on until you have tested the whole flow |
| Test secret key | `sk_test_…` |
| Live secret key | `sk_live_…` |
| Webhook secret | `whsec_…` (given once when your account is created) |
| API base URL | `https://api.xaalis.sn` (leave the default) |
4. Copy the **webhook URL** shown at the top of that page — `https://your-store/wp-json/xaalis/v1/webhook` — and set it as your account’s `webhook_url` (`PATCH /v1/account`, must be `https://`; see [Webhooks](/guides/webhooks/)).
Saved secrets are never shown again: the fields stay empty with a masked hint (`sk_test_••••ab12`). Leave a field empty to keep its value. Live mode is refused without a `sk_live_` key and an `https://` API URL, and test mode never uses a live key.
The payment method is hidden at checkout when the currency isn’t XOF or when the current mode has no valid key; an admin notice says why.
## What happens at checkout
[Section titled “What happens at checkout”](#what-happens-at-checkout)
```
sequenceDiagram
participant C as Customer
participant W as WooCommerce
participant X as Xaalis
C->>W: Place order (Wave / Orange Money)
W->>X: POST /v1/payments (Idempotency-Key wc:mode:order:amount)
X-->>W: payment + checkout_url
W-->>C: redirect to checkout_url (order "Pending payment")
C->>X: pays with Wave / Orange Money
X->>W: signed payment.succeeded webhook
W->>W: verify, check order/amount/mode, payment_complete() once
X-->>C: back to the order-received page
```
* The payment is created with the order total as an **integer** XOF amount, `client_reference` = order number, `metadata` = `{order_id, order_key}`, `success_url` = the order-received page.
* A total with decimals (e.g. `15000.50`) is **refused** with an error, never rounded. Totals must be between 100 and 100 000 000 XOF.
* Retrying the checkout returns the same Xaalis payment (same Idempotency-Key). If the cart total changes, a new payment is created. If the customer retries after a failed or expired payment, a new one is created too.
## When is an order paid?
[Section titled “When is an order paid?”](#when-is-an-order-paid)
Only when Xaalis confirms it — **never** because the customer reached the “thank you” page ([Never trust the redirect](/guides/payments/#never-trust-the-redirect)):
1. the signed `payment.succeeded` webhook, or
2. when the customer lands on the order-received page, the plugin asks `GET /v1/payments/{id}` server-side.
Either way the same checks run before anything changes: the payment is the one created for this order (id, `order_id` and `order_key` in `metadata`), `livemode` matches the store’s mode, the currency is XOF and the amount equals the order total. Until then the page says **“Payment awaiting confirmation”**.
| Event | Order |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `payment.succeeded` | **Processing** (stock reduced, emails sent) — exactly once, even if the webhook is replayed or races the return page |
| `payment.failed` | **Failed** (the customer can retry from their account; a new payment is created) |
| `payment.expired` | **Cancelled** (if still pending) |
| succeeded, but the order total changed since | **On hold** with a note: review, then refund or adjust |
| a second payment succeeds on a paid order | note “refund one payment” |
The webhook answers `400` to a bad, missing or expired signature, `503` while no webhook secret is configured (so Xaalis retries), and `200` otherwise. Every order shows its Xaalis payment id on the order screen; the transaction id is the payment id once paid. Logs: **WooCommerce → Status → Logs**, source `xaalis` (ids and codes only, never keys).
## Test mode
[Section titled “Test mode”](#test-mode)
With a `sk_test_` key no real money moves. Place an order, then approve it on the hosted checkout, or from your server with `POST /v1/payments/{id}/simulate` `{"outcome":"succeeded"}` (see [Test mode](/guides/test-mode/)). The order should move to **Processing** within seconds.
## Developing the plugin
[Section titled “Developing the plugin”](#developing-the-plugin)
`integrations/woocommerce/dev` has a disposable WordPress + WooCommerce (Docker, `http://localhost:8480`) wired to a local Xaalis API and a fresh test merchant:
```bash
integrations/woocommerce/tests/docker-test.sh # php -l + unit tests
integrations/woocommerce/dev/setup.sh # WordPress, WooCommerce, XOF store, 15 000 FCFA product, plugin configured
integrations/woocommerce/dev/e2e.sh # payment, webhooks, replays, forged signatures, failure, expiry, blocks
docker compose -p xaalis-woo -f integrations/woocommerce/dev/docker-compose.yml down -v
```
Details: `integrations/woocommerce/xaalis-for-woocommerce/README.md`. Building your own PHP integration instead? Use the [PHP SDK](/guides/sdk-php/).
# PI-SPI integration spike
**Status:** design only, 2 October 2026. No PI-SPI money-moving code or credentials are configured in Xaalis.
## Decision
[Section titled “Decision”](#decision)
Xaalis should integrate as a business client of an authorized PI-SPI participant through that participant’s **API Business**, subject to a signed partner agreement. The BCEAO describes the API Business as the standard interface that participants expose to enterprise clients; its public lists distinguish participants allowed to offer PI-SPI to the public from participants whose API Business has been homologated. Neither list grants Xaalis API access by itself. Confirm the chosen partner appears on the applicable lists and supplies its own sandbox and production contract before implementation. [BCEAO API Business guide](https://developer.pispi.bceao.int/guides/api-business), [authorized participants](https://www.bceao.int/fr/documents/liste-des-participants-autorises-ouvrir-les-services-de-pi-spi-au-public), [API Business homologation](https://downloads.bceao.int/fr/communique-presse/homologation-des-api-business-de-la-plateforme-interoperable-du-systeme-de).
The first useful product is **merchant collection**, using a dynamic QR tied to one Xaalis payment if the partner can return a stable transaction identifier and confirm the received payment’s amount, currency, beneficiary, and final status. A request to pay is a second candidate when the payer has a verified PI-SPI alias; the current checkout phone field is not proof of an alias. A static QR cannot safely identify one hosted-checkout payment without a partner-supported correlation mechanism. The BCEAO’s QR guide requires a unique TxID for dynamic QR, while the request-to-pay guide requires payer and payee aliases. [QR generation](https://developer.pispi.bceao.int/guides/qr-generation), [request to pay](https://developer.pispi.bceao.int/guides/rtp-envoyer).
## The funds question comes first
[Section titled “The funds question comes first”](#the-funds-question-comes-first)
| Partner settlement model | Xaalis behavior |
| ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Funds settle into a contractually permitted Xaalis-controlled collection account, with a reconciled liability to each merchant | The existing `PAYMENT_CAPTURED` and merchant available-balance model may be extended after legal, safeguarding, fee, reconciliation, and payout terms are verified. Credit only against an irrevocable, correctly matched receipt in that account. |
| Funds settle directly into each merchant’s account or alias | Do **not** credit Xaalis merchant available balances or offer Xaalis payouts against those receipts. This is a different direct-settlement product and needs its own resource and reporting design. |
The public API Business documentation does not decide which model Xaalis’s partner will provide. The existing ledger assumes Xaalis can account for and subsequently pay out captured merchant funds. A status notification alone does not establish custody or settlement. The partner must provide beneficiary-account ownership, fees, settlement statements, reversals, and daily reconciliation evidence before enabling live collections.
## Fit with the current backend
[Section titled “Fit with the current backend”](#fit-with-the-current-backend)
`packages/connectors/src/types.ts` defines `PaymentConnector`. `createCheckout()` returns a `providerRef` and a redirect or QR next action; `getPayment()` returns the provider state; `verifyWebhook()` normalizes inbound notifications. `apps/api/src/services/payments.ts` owns checkout initiation and `applyPaymentState()` owns payment state and the deterministic ledger capture. `apps/api/src/services/events.ts` persists the inbox event before the worker processes it. These boundaries remain useful for PI-SPI, but no endpoint or payload should be hard-coded from the public sandbox examples.
| Existing method or boundary | Proposed PI-SPI mapping, contingent on partner proof |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createCheckout(paymentId, amount, …)` | Ask the participant for a single-use dynamic QR or request to pay. Send a stable Xaalis reference only if the partner guarantees it is echoed in notifications and status lookups. Persist the participant’s immutable transaction ID as `providerRef` before exposing the next action. If the QR is generated locally, do not start checkout until the partner’s correlation and lookup contract is proven. |
| `getPayment(providerRef)` | Query the participant’s authoritative transaction status and verify recipient account/alias, XOF amount, and our payment reference. Map `IRREVOCABLE` to `succeeded`, explicit `REJETE` to `failed`, and `INITIE`, `ENVOYE`, `CONFIRME`, unknown, and transport errors to `processing`. Never infer success from QR display, a customer return, or a webhook label alone. [BCEAO payment states](https://developer.pispi.bceao.int/guides/paiement-status). |
| `verifyWebhook(rawBody, headers)` | Terminate participant mTLS at the trusted ingress and verify the partner’s HMAC over the **raw bytes** before storing the event. The BCEAO sandbox guide describes HTTPS, participant mTLS, and `X-Signature` HMAC-SHA256; the partner must confirm exact certificate trust, signature encoding, secret rotation, event identity, and retry rules. Treat uncertain or merely provisional events as hints and re-fetch before credit. [BCEAO webhook security](https://developer.pispi.bceao.int/guides/webhook-securiser). |
| `createPayout()` / `getPayout()` | Remain disabled until the partner separately grants payment-initiation scope, an account debit mandate, recipient-alias validation, a stable idempotency/reference contract, and authoritative final lookup. The current `recipientPhone` is insufficient for PI-SPI alias, IBAN, or account-plus-participant routing. A future connector may need separate collection and disbursement capabilities rather than treating every `PaymentConnector` as payout-capable. [BCEAO send-payment guide](https://developer.pispi.bceao.int/guides/paiement-envoi). |
The public send-payment guide lists `POST /paiements-envoyes` in its endpoint table but uses `POST /paiements` in examples. Its examples also show a central sandbox hostname, whereas the API Business overview says participants expose the interface. This is a contract ambiguity, **not** a choice for Xaalis to guess. Resolve it against the chosen participant’s versioned OpenAPI and sandbox responses before writing a connector. [BCEAO send-payment guide](https://developer.pispi.bceao.int/guides/paiement-envoi), [API Business overview](https://developer.pispi.bceao.int/guides/api-business).
The current `Provider` union, Postgres enum, checkout provider selection, fee configuration, reconciliation jobs, and public OpenAPI contain only Wave and Orange Money. They need additive, generated migrations and explicit UI/API exposure if PI-SPI is eventually enabled. Test keys must still use the mock connector and `test:` ledger accounts; a sandbox PI-SPI credential must never be selected by a `sk_test_` request.
## Proposed collection sequence
[Section titled “Proposed collection sequence”](#proposed-collection-sequence)
1. Create a regular Xaalis payment in `requires_payment_method`; the merchant and amount are immutable for this attempt.
2. On PI-SPI selection, create one participant-side request/QR using a stable Xaalis payment reference. Persist its `providerRef`, amount, payee identity, and next action. A timeout after submission triggers lookup by that reference before any retry; it must not create a second charge.
3. The customer authorizes in a PI-SPI-capable app. The browser only displays progress; it cannot mark the payment paid.
4. The inbound route authenticates the participant, verifies raw-body HMAC, stores a deduplicated event, enqueues a job, and acknowledges quickly. The BCEAO guide calls for a response within five seconds. [BCEAO webhook security](https://developer.pispi.bceao.int/guides/webhook-securiser).
5. The worker obtains authoritative final status and checks the exact payment reference, amount, XOF currency, recipient account/alias, and transaction ID. Only `IRREVOCABLE` can reach `applyPaymentState(..., "succeeded")` and post `payment::captured` once. Unknown status or lookup failure keeps the payment processing. Rejected payments do not credit the ledger.
6. Reconcile participant receipts and settlement statements against Xaalis payments, ledger references, and the controlled bank balance. Duplicate notifications and repeated status lookups must not create another credit.
The public API reference shows `GET /paiements-recus` filters including payee alias, `txId`, amount, and irrevocability date; it may help reconciliation, but the chosen participant must confirm completeness, pagination, retention, and whether it is the authoritative source. [BCEAO received-payments reference](https://developer.pispi.bceao.int/api-business/paiementRecuLister).
## Partner evidence required before code or live enablement
[Section titled “Partner evidence required before code or live enablement”](#partner-evidence-required-before-code-or-live-enablement)
1. Signed commercial and regulatory arrangement: Xaalis’s role, custody or direct settlement, permitted fee model, merchant sub-account/alias model, refunds/returns, charge disputes, and who bears loss.
2. Participant identity and proof of public-service authorization and API Business homologation; versioned sandbox and production OpenAPI, changelog, support and incident contacts.
3. Sandbox account and aliases, OAuth client and scopes, API key, mTLS certificate chain, webhook signing secret, callback registration, and credential-rotation procedure. Public guidance describes OAuth2, certificate-bound tokens, mTLS, and API keys, but exact partner values and URLs must come from the partner. [BCEAO security guide](https://developer.pispi.bceao.int/guides/securite-overview).
4. Sanitized real sandbox fixtures for dynamic QR or request to pay, `IRREVOCABLE`, `REJETE`, pending/unknown states, duplicated notification, amount/payee mismatch, lookup timeout, and payment-initiation retry. Confirm the identifier shared among initiation, event, received-payment lookup, and settlement statement.
5. Production reconciliation files or API, retention and pagination rules, settlement timing, reversals and returns, fee deductions, and a negative test proving a receipt to a different beneficiary cannot credit a Xaalis merchant.
## Exit criteria for a later implementation task
[Section titled “Exit criteria for a later implementation task”](#exit-criteria-for-a-later-implementation-task)
* The chosen partner’s contract resolves the endpoint discrepancies and funds model above; a production rollout decision is recorded separately.
* A connector fixture suite covers final, pending, rejected, unknown, mismatched amount/recipient, duplicate, timeout, and retry outcomes. Unknown outcomes preserve money and never trigger another send.
* Real sandbox checkout and payout tests, if payout is in scope, prove one deterministic provider action and one ledger entry under retries and concurrent workers.
* Reconciliation ties every credited payment to an irrevocable partner receipt and funds in the correct settlement account before PI-SPI is exposed to live merchants.
This spike does not authorize live PI-SPI traffic or remove any current Wave/Orange Money guard.