Aller au contenu

Merchant developer controls

Ce contenu n’est pas encore disponible dans votre langue.

OpenAPI 0.9.0 adds MerchantDeveloperApi for server-side merchant-identity integrations. The browser uses same-origin BFF session cookies and CSRF protection. Do not embed integration keys or OAuth tokens in browser bundles or storage.

Owner/Admin/Developer can access /portal/merchants/{merchant}/keys and /portal/merchants/{merchant}/webhooks/.... All requests require X-Futa-Membership-Version; mutations require Idempotency-Key. Finance/Support cannot manage developer controls. /keys returns metadata and allowed_permissions for the current role. The same three roles also hold sandbox:use from OpenAPI 0.49.0 (sandbox runs and, from 0.50.0, participant status) and requests:read from 0.51.0 (the request log) (D-034); neither is ever an API key scope.

From OpenAPI 0.45.0, the merchant API itself requires recent auth_time for key issue (POST …/keys), rotation (POST …/keys/{id}/rotate) and revocation (POST …/keys/{id}/revoke), as the portal BFF does (D-027). futa.step-up.max-age defaults to five minutes and is configurable from one to fifteen minutes; exactly the maximum age is accepted, with at most sixty seconds of future clock skew. Missing, invalid, older or further-future auth_time returns 403 STEP_UP_REQUIRED before writes or idempotency consumption.

Portal-issued keys cannot grant keys:manage. A Developer can delegate payments:read, payments:write, webhooks:read and webhooks:manage; an Owner or Admin can also delegate refunds:read, refunds:write and settlements:read, and, from OpenAPI 0.59.6, the payout scopes payouts:read, payouts:write and balance:read, which only an Owner’s or an Admin’s delegation gives: a Developer never does, and neither does keys:manage alone (see payouts). Keys are bound to the issuing subject’s membership version. A role change or revocation permanently invalidates that subject’s delegated keys. A new membership never restores them. Integration credentials issued through staff provisioning remain separate.

From OpenAPI 0.47.0, no key grants keys:manage to the keys it creates or rotates (D-034): on /v1/keys, asking for it, or rotating a key that holds it, returns 403 SCOPE_ESCALATION. Only a credential provisioned outside a key (the sandbox bootstrap key or staff maker-checker issuance) can manage keys there; people manage keys through these portal routes, behind a recent sign-in. Keys that another key had created or rotated with keys:manage before this rule have lost it, and one that held nothing else was revoked. From 0.55.0 the account journal shows each such key by its label, with the system as actor: key.permissions_reduced, or key.revoked.

Create a key with { "label": "Test checkout", "permissions": ["payments:read", "payments:write"] }. Save its one-time secret in your server’s secret manager. Metadata never returns a secret. Rotation atomically revokes the predecessor; copy the replacement. If a response is lost, repeat the same body and key. A completed secret command returns 409 SECRET_ALREADY_ISSUED, and a changed body returns 409 IDEMPOTENCY_CONFLICT. Inspect the key list and revoke/replace any lost credential. Never retry with fresh idempotency keys automatically.

The webhook endpoint routes support registration and secret rotation. Deliveries are listed a page at a time with each event’s state and attempts; replay preserves the event ID and is rate limited (see delivery history and replay). Receivers must verify the timestamp and HMAC and deduplicate event IDs. Only the explicit local synthetic receivers are allowed. Event subscriptions and versioned endpoint updates are available; see the configuration workflow below. A lost webhook secret requires a new deliberate rotation.

The portal displays secrets only until hidden, navigation/context change, or 60 seconds. Secrets are not placed in TanStack caches, browser storage, URLs, logs, audit or durable command receipts. This is a test-mode increment: live provisioning and external egress are not enabled.

Read /webhooks/endpoint for config_version and events. POST /webhooks/endpoint/configuration with url, an explicit events array and version equal to the observed config_version; retain the same idempotency key on retries. The integration API prefix is /v1; the merchant-identity prefix is /portal/merchants/{merchant}. Identity requests still require the current membership version header. A stale configuration returns 409 WEBHOOK_CHANGED; reload before starting a new deliberate edit. Empty events selects none. Unknown event types and destinations outside the local allowlist are rejected.

Selection is recorded when a Kafka event enters the delivery queue. Queued events keep their original decision; endpoint changes apply to their subsequent attempts. Changing subscriptions never silently sends previously filtered events. Delivery metadata includes event_type, selected and configuration_version. Manual replay checks current subscriptions and returns 409 EVENT_NOT_SUBSCRIBED for an excluded type. After enabling that type, explicit replay can select the filtered event without changing its event ID. Configuration changes do not rotate the signing secret.

GET …/webhooks/deliveries (or /v1/webhooks/deliveries with webhooks:read) lists deliveries newest first, by creation time then event ID, a page at a time (D-034). limit takes 1 to 100 (100 by default) and next_cursor is null on the last page, so without cursor and limit the first page is still the latest 100. The history lasts as long as the deliveries table keeps it; it has no retention period of its own. A cursor is sealed to its list and merchant: another merchant’s, altered or invented cursor returns 400 INVALID_CURSOR.

Each delivery states its state, from the recorded facts by the database clock: DELIVERED once a 2xx was recorded, NOT_SELECTED when the subscription filtered the event out, EXPIRED when the retry window closed undelivered, PENDING before any attempt and RETRYING after a failed attempt within the window. A payment event carries its payment_reference (FT-…); other events carry null. …/deliveries/{id}/attempts returns every recorded attempt in order, with the time it was recorded and the HTTP status the destination answered; status 0 is a transport error, with no HTTP answer (connection refused, reset or timed out). No duration is recorded, so latency is not available.

A replay keeps the event ID and its attempts and reopens the 72-hour retry window from the replay, so an EXPIRED or DELIVERED event returns to PENDING or RETRYING. At most one new replay per event is accepted each minute. An event type the current subscription excludes returns 409 EVENT_NOT_SUBSCRIBED.

The console’s « Simulateur » starts synthetic payments (DEV-02, D-034); Owner, Admin and Developer use it with sandbox:use. GET …/sandbox/scenarios lists the six scenarios (success, decline, expiry, uncertain, late-success, unavailable), the synthetic test number each one uses (those of the quick start) and the amount rules: the currencies (CDF and USD), the multiple of minor units each requires, and the minimum and maximum amount. It exposes no simulator credential or callback.

POST …/sandbox/runs takes an Idempotency-Key and { "scenario": "decline", "amount": 12500, "currency": "USD" }, and starts one synthetic payment. The merchant API builds that payment itself (the scenario’s test number, method simulator, a merchant reference naming the run and a fifteen-minute expiry) and sends it through the same direct creation and normal sandbox admission as POST /v1/payments. The creation’s amount and currency checks, your risk limits and the participant’s maximum apply, and a refused admission answers 409 with its code, such as MERCHANT_LIMIT_EXCEEDED. No simulator credential, callback or destination comes from the caller, and no checkout capability is returned. The 201 returns the run with its payment_id and payment_reference, never an outcome: read the outcome from the payment and its detail.

A run keeps its terms for your key and sends them under one switch idempotency key. After a lost answer or a 503 (SWITCH_UNAVAILABLE or INVALID_CREATION_RESPONSE), retry with the same key, never a new one: the retry reaches the same payment and nothing starts a second. Other terms under the same key return 409 IDEMPOTENCY_CONFLICT. Retried after its fifteen minutes, a run whose payment was admitted answers with that payment; one never admitted returns 400 INVALID_REQUEST, and a new run needs a new key. An uncertain payment never starts another. From 0.52.0, the prefix sandbox-run-, whatever its letter case, is reserved for these runs: POST /v1/payments refuses an Idempotency-Key or merchant_reference that starts with it (400 INVALID_REQUEST).

GET …/sandbox/participant-status (DEV-08, D-034, sandbox:use) relays switch-core’s developer projection: for each of the four sandbox participants (simulator, synthetic-bank, sim-wallet, sim-bank; real names wait for Q-A4), the HTTP exchanges the switch recorded with it over the last thirty days of complete minutes. The merchant API reads it with its own switch client: you hold no switch credential, and the read names no merchant and sends no probe or payment. The answer is Cache-Control: no-store.

  • window is [from, to), to being the read’s minute by the database clock. From 0.55.0, dates counts the Kinshasa calendar dates it touches (30 when it starts at their midnight, otherwise 31), and a participant’s covered_days never exceeds them. Samples of the latest minutes can still arrive, so a later read may count more for them.
  • exchanges gives answered out of attempted, as decimal strings with their answered_rate. An exchange counts as answered when it got an HTTP answer, completed HTTP errors included, so the ratio reads « Échanges répondus »: it is neither successful payments nor uptime. Recording is opt-in and samples can be lost, so the coverage is stated (covered_days and covered_minutes, from coverage_from) and nothing is reconstructed.
  • latency.p95 is a histogram bound in milliseconds, above: true beyond the last bound (10,000 ms).
  • NOT_MEASURED (recording off, no exchange, no response or an incomplete histogram) reads « non mesuré », never zero.
  • last_observed_at keeps the minute of the newest sample however old it is. From 0.55.0, it, coverage_from and the window’s bounds are whole minutes, never finer, as every merchant shares the sandbox participants; observed_at and suspended_since keep their precision.
  • route_control shows a collection route switched off or suspended, apart from the measurement.
  • unavailable lists what is not shown and why: uptime (not measured), status labels (no publication policy set), incident notices (not built) and participant names (Q-A4).

The relay checks the answer field by field against developer-participant-status-v1 and what the switch guarantees beyond it. Any failure, refusal or other answer returns 503 PARTICIPANT_STATUS_UNAVAILABLE (« indisponible »), never zero or mixed figures. The answer gives no uptime, status or health label and no incident notice.

The request log (DEV-05, D-034) is separate from the account journal. It keeps one entry per authenticated merchant /v1 call for thirty days, as metadata only. Owner, Admin and Developer read it with requests:read, and each read spends the merchant request window.

  • An entry is captured once at the API boundary, after the key is recognised, so scope (403 INSUFFICIENT_SCOPE) and rate (429 RATE_LIMITED) refusals and application errors are included. A call with a missing or invalid key belongs to no merchant and is never logged.
  • An entry holds id (the server request ID, answered as X-Request-Id, never a value the caller sent), method, route (the matched template, such as /v1/payments/{id}, or unmatched; never the raw path or query), status, error_code, key_id and key_label (never the secret), environment (test), started_at and completed_at (UTC, by the database clock) and duration_us (monotonic microseconds at the API boundary, upstream waits included). It keeps no body, raw URL or query, credential, idempotency value, personal data, IP or user agent.
  • error_code is the refusal’s error.code as answered when the request log’s catalogue holds it (the codes this API answers and the switch-core codes it relays); otherwise it is null and the entry is kept all the same. See error bodies.
  • GET …/requests lists entries newest first (start time, then ID), limit 1 to 100 (50 by default), filtered by status (a class, 1xx to 5xx), method, route, key, from (inclusive) and to (exclusive). next_cursor is sealed to the list, its filters and the merchant: another merchant’s or list’s cursor, one read with other filters, or an altered or invented one returns 400 INVALID_CURSOR. The page size may change between pages. Unknown parameters return 400 INVALID_REQUEST.
  • GET …/requests/summary takes the same filters and gives requests, status_classes and the observed duration_us (nearest-rank p50, p95, p99 and maximum, null without any entry) over exactly the entries the list shows.
  • GET …/requests/{id} returns one entry; another merchant’s, or one older than thirty days, returns 404 REQUEST_NOT_FOUND.
  • Every page and summary states its coverage: the window read, captured_since (nothing earlier was captured), missed (calls whose capture failed or gave up a lock wait, counted per minute), unattributed (calls without a valid key are not logged) and the purge’s last completed and failed runs. Reads keep to the last thirty days by the database clock whether or not the purge has run.