Integration guide: from zero to certified
status: one end-to-end path for a PAM team. Written from ADR-059, ADR-060, ADR-062, ADR-064, ADR-065 and the code. Labels: [BT] built and tested, [FO] built with the flag off, [NB] not built. Items marked "(on branch, not released yet)" are not on origin/integration/final.
1. What you build and what Neun builds
- You build the wallet: a set of HTTPS endpoints on your PAM. Neun is the only client. You are the server. [BT]
- You build your own adapter on your side. Neun gives you an API, an SDK and an Event Connector.
- Neun calls your wallet to take and pay money. Money moves only through the wallet calls. Events never move money. [BT]
- Neun sends you events (what happened) by signed webhook or a pull feed. [FO]
2. The wallet endpoints
Contract file: contracts/openapi/wallet-v1.json. Paths are added to your walletBaseUrl. [BT]
| Endpoint | Purpose | Business rejection codes (in a signed 200 with status: REJECTED) |
|---|---|---|
POST /v1/debit | Take money from the player before a buy, or for a correction | INSUFFICIENT_FUNDS, PLAYER_NOT_FOUND, PLAYER_BLOCKED, SELF_EXCLUDED, RG_LIMIT_EXCEEDED, KYC_REQUIRED, JURISDICTION_BLOCKED, SESSION_INVALID, CURRENCY_MISMATCH, TRANSACTION_LIMIT_EXCEEDED, ACCOUNT_CLOSED, ROLLED_BACK |
POST /v1/credit | Pay the player: sell, settlement, correction. Never refuse for account status. | PLAYER_NOT_FOUND, ACCOUNT_CLOSED, CURRENCY_MISMATCH |
POST /v1/refund | Return all or part of an earlier successful debit | ORIGINAL_NOT_FOUND, ORIGINAL_ROLLED_BACK, REFUND_EXCEEDS_ORIGINAL, PLAYER_NOT_FOUND, ACCOUNT_CLOSED, CURRENCY_MISMATCH |
POST /v1/rollback | Cancel a debit whose outcome Neun does not know. Answer REVERSED or TOMBSTONED. | none: never reject a rollback |
POST /v1/balance | Show the balance (capability balance, optional) | PLAYER_NOT_FOUND, CURRENCY_MISMATCH |
GET /v1/health | Liveness. 200 with a signed body, 503 when down. | none |
Every call carries an operationId. It is the idempotency key. It is the same on every attempt.
Answers:
- A business outcome is always HTTP 200, signed, with
statusSUCCEEDEDorREJECTED. Insufficient funds is a 200 too. - Protocol errors use a body
{ "code", "message" }:INVALID_SIGNATURE(401),STALE_REQUEST(401),REPLAYED_NONCE(401),UNSUPPORTED_VERSION(400),VALIDATION_FAILED(400 or 422),IDEMPOTENCY_CONFLICT(409). - Neun treats every answer that is not a signed 200 as unknown.
How Neun signs its calls and how you check them
Request headers: Neun-Wallet-Version, Neun-Key-Id, Neun-Timestamp, Neun-Nonce, Neun-Signature, Neun-Attempt, Neun-Correlation-Id, Neun-Operator-Id.
Check in this order: version is 1, else 400 UNSUPPORTED_VERSION; signature matches (constant time), else 401 INVALID_SIGNATURE; timestamp within 300 seconds (300000 ms, 5 minutes) of your clock, else 401 STALE_REQUEST; nonce not seen in 600 s, else 401 REPLAYED_NONCE.
Sign every response with Wallet-Key-Id, Wallet-Timestamp and Wallet-Signature, bound to the request nonce. The exact formulas and test vectors are in the signing guide.
Secrets: the request key starts with npwq_ and the response key with npws_. They are made in the Back Office and shown once (ADR-064). The HMAC key is the full secret text, prefix included. Keep them in a secret manager. Never put them in code, logs or tickets.
Neun-Timestamp is Unix time in milliseconds. The published wallet-v1.json says seconds by mistake; see section 9.
3. Money rules your wallet must follow
- Same
operationId, same body: return the first outcome withalreadyProcessed: true, the samewalletTransactionIdand the samestatus. Do not move money again. - Same
operationId, different body: HTTP 409IDEMPOTENCY_CONFLICT. Move no money. Neun stops retrying that operation and opens a recovery case. - Keep every
operationIdand its outcome for at least 400 days. - Save the money change and the stored outcome in one transaction.
- Rollback of a debit you never saw: store a tombstone and answer
TOMBSTONED. If that debit arrives later, answerREJECTEDwithROLLED_BACK. - Accept refunds against debits of any age. The sum of refunds never exceeds the debit.
- Accept your wallet answering any spacing between retries.
Retries after an unknown outcome
ADR-059 s10.3 is the target. Release 1 does something different today. Open decision: not decided yet (owner row N183).
| Operation | ADR target | What Release 1 does today |
|---|---|---|
| DEBIT | 0.5, 1, 2, 4, 8 s within a 30 s window, then ROLLBACK | Re-sent within the 30 s window, then rolled back |
| ROLLBACK | 1, 2, 5, 10, 30, 60 s, then every 5 min | Every 60 s until final |
| REFUND (buy) | 1, 5, 30 s, 2 and 10 min, then every 30 min | Every 60 s until final |
| CREDIT (sell) | same as REFUND | Every 60 s; a case opens after 5 unknown attempts, retries go on |
| CREDIT (settlement) | same as REFUND | Settlement run backoff: 30 s rising to a 300 s cap |
4. Run the conformance kit (W-01..W-27)
The kit tests your wallet. CLI results are informative only. Certification uses hosted runs. [BT] The CLI package @neungaming/wallet-conformance 0.1.0 is published on the Neun GitHub Packages registry. [BT]
Install it from the registry (see the quickstart for the .npmrc setup):
mkdir try && cd try && npm init -y
npm install --ignore-scripts @neungaming/wallet-conformance
npx neun-wallet-conformance --help
No registry access yet? Ask Neun for the tarball and run `npm install --ignore-scripts ./neungaming-wallet-conformance-0.1.0.tgz`.
Run it (secrets only from the environment; use sandbox keys; never production keys):
NODE_ENV=test NEUN_WALLET_REQUEST_SECRET=<your npwq_ sandbox key> NEUN_WALLET_RESPONSE_SECRET=<your npws_ sandbox key> \
neun-wallet-conformance --sandbox-confirmed --allow-remote-sandbox \
--base-url <your https wallet url> --key-id <request key id> --wallet-key-id <response key id> \
--operator-id <uuid> --brand-id <uuid> --currency EUR --other-currency USD \
--funded <ref> --low <ref> --self-excluded <ref> --blocked <ref> --report report.json
You need four test players per currency: FUNDED, LOW, SELF_EXCLUDED, BLOCKED.
Exit codes: 0 every scenario PASS; 1 at least one FAIL; 2 refused or bad usage; 3 no FAIL but at least one SKIPPED.
Report (JSON): { kit, startedAt, target, totals: { PASS, FAIL, SKIPPED }, done, results: [{ id, title, verdict, detail? }], notRun }. done is true only when every scenario passed.
| ID | What it checks |
|---|---|
| W-01 | Signed HEALTH; your clock within 5 s of Neun |
| W-02 | DEBIT for a funded player |
| W-03 | Same DEBIT replayed 3 times, also at once: money taken once |
| W-04 | Same operationId, other amount: 409 IDEMPOTENCY_CONFLICT |
| W-05 | DEBIT for a low player: INSUFFICIENT_FUNDS, also on replay after top-up |
| W-06 | Self-excluded player: SELF_EXCLUDED |
| W-07 | Blocked player: PLAYER_BLOCKED |
| W-08 | Unknown player: PLAYER_NOT_FOUND |
| W-09 | Wrong currency: CURRENCY_MISMATCH |
| W-10 | Too many decimals: 400 VALIDATION_FAILED |
| W-11 | ROLLBACK of a debit, replayed: money returned once |
| W-12 | ROLLBACK first, then the debit: later debit is ROLLED_BACK |
| W-13 | DEBIT and ROLLBACK at the same time, 50 times: never double movement |
| W-14 | Full REFUND |
| W-15 | Partial refunds; the one over the remainder is REFUND_EXCEEDS_ORIGINAL |
| W-16 | REFUND of an unknown debit: ORIGINAL_NOT_FOUND |
| W-17 | REFUND of a rolled-back debit: ORIGINAL_ROLLED_BACK |
| W-18 | CREDIT replayed: paid once |
| W-19 | CREDIT to self-excluded and blocked players: both OK |
| W-20 | Wrong signature: 401 INVALID_SIGNATURE |
| W-21 | Timestamp 301 s old: 401 STALE_REQUEST |
| W-22 | Nonce reused within 600 s: 401 REPLAYED_NONCE |
| W-23 | Every 200 response is signed and bound to the request nonce |
| W-24 | Unknown extra fields are ignored |
| W-25 | Load: 1,000 debits, 99% within 1,000 ms, none over 3,000 ms |
| W-26 | 10 debits at once, balance covers 4: exactly 4 OK, never negative |
| W-27 | Plain HTTP is refused or redirected; SKIPPED on a loopback http wallet |
5. Receive events
Contract file: packages/contracts/asyncapi/events-v1.json. The list of event types is on the events page. [FO]
- Envelope: each event is a CloudEvent 1.0 in JSON. It has
neunpartitionkey,neunresourceversionandneunsequence. - Order: apply an event only when its
neunresourceversionis higher than the one you hold. Do not use arrival time. - Webhook: a JSON array of 1 to 500 events, at most 1 MB. Header
Neun-Signature: t=<unix ms>,v1=<hex>. The hex is lowercase. Reject atolder than 300 s (5 minutes). Compare in constant time. Hash the raw bytes. - Retries: answer 2xx within 10 s. Otherwise Neun sends again after 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, then every 4 h. After 72 h of failure the subscription is suspended.
- Delivery is at least once. Remove duplicates by event
id. - Pull feed instead of webhooks:
GET /v1/s2s/integration-eventswith scopeevents:read. [BT] - Test your receiver:
POST /v1/s2s/event-subscriptions/{id}/testsends one signed test event. [FO] - Server SDK helpers
parseEventBatch(checks signature and limits) andpollEvents(reads the pull feed) are in@neungaming/predictions-server(on branch, not released yet). - Kafka delivery and bootstrap exports: not decided yet (owner row N14). [NB]
- A typed client generated from the OpenAPI: not decided yet (owner row N199). [NB]
6. Certification path
ADR-065 has three environments: SANDBOX (build and self-test), STAGE (certification and joint acceptance), PROD (live).
- SANDBOX: run the kit yourself until every W scenario passes.
- Hosted run by Neun in SANDBOX or STAGE. [FO] Flag
CONFORMANCE_RUNNER_ENABLED, default off. - STAGE: Neun and you run the joint acceptance scripts and both sign the results.
- Sign-off: the Neun Integration Lead and your technical owner sign in the Back Office, each with step-up re-authentication.
- Only then can PROD credentials be approved and the PROD trading flag switched on.
- Hypercare days 1 to 7 at half the order limit.
Wallet and security scenarios cannot be waived.
Validity: until a recertification trigger, for example a new wallet host or 12 months since the last certification. Expiry is behind CERTIFICATION_EXPIRY_ENABLED, default off. [FO]
Runs are signed with HMAC today. An Ed25519 option exists (on branch, not released yet). Signing keys: not decided yet (owner rows N145, N177).
Before real money, Neun needs owner values, names only: PAM_GO_LIVE_REF: not decided yet (owner row N52).
7. Environments
- There is no dev environment for you yet: not decided yet (owner rows C21, C22). Cloud, registry and secret store are not chosen.
- What you get and when: to be agreed with Neun (owner and DevOps).
- See the sandbox guide.
8. Checklist
- [ ] Six endpoints built: debit, credit, refund, rollback, health, and balance if you want it.
- [ ] Request signature, timestamp and nonce checked; every response signed.
- [ ] Every business outcome is a signed 200.
- [ ] Idempotency by
operationId, 409 on a different body, 400 days of storage. - [ ] Rollback tombstones; late debits answer
ROLLED_BACK. - [ ] Credits and refunds never refused for account status.
- [ ] Wallet accepts any retry spacing.
- [ ] Secrets in a secret manager, never logged.
- [ ] Kit run: exit code 0,
done: true. - [ ] Event receiver checks the signature, removes duplicates, orders by resource version.
- [ ] Test event received.
- [ ] Hosted run and STAGE acceptance signed.
9. Known differences between the ADR text and today's behaviour
| Topic | ADR text | Today | Owner row |
|---|---|---|---|
| Wallet retry cadence | ADR-059 s10.3 timings | Fixed 60 s or the settlement backoff (section 3) | not decided yet (owner row N183) |
| Operator API key reused with a different body | ADR-060: 409 | 422 IDEMPOTENCY_KEY_REUSED | not decided yet (owner row N45) |
correction.applied partition key | ADR-062:95: correctionId | correctionId (the internal Kafka key stays marketId, ADR-006:32) | none, decided (N187) |
Idempotency-Key header in operator-v1 | ADR-060: required | Required at runtime, optional in the schema | not decided yet (owner row N162) |
Neun-Timestamp unit | ADR-059: milliseconds | The published wallet-v1.json on final says Unix seconds by mistake; the ADR and the signer use milliseconds; the contract text is being corrected (not released yet) | none |