Build
Testing and test mode
What test mode guarantees, what you can exercise offline, and a pre-launch checklist.
Test mode exists so you can build the whole integration without moving real ANM, and the guard that enforces it fails closed.
What test mode guarantees #
- A test key can never spend real ANM. Every ANM-spending path calls a guard that throws for a test-mode subject, and anything not positively identifiable as a live key is refused rather than guessed. There is no "probably live" branch.
- Test and live data are separate. Test intents, test payments and test webhooks never appear in live reports.
- The shape is identical. Same endpoints, same lifecycle, same signatures, same error codes. Only the prefix changes.
// src/api/keys.js — the guard itself
function assertCanMoveRealFunds(subject) {
if (isTestMode(subject)) throw new KeyError('test-mode key cannot move real ANM');
}
Switching modes #
Use the test pair while building, the live pair when you are ready:
| Publishable | Secret | |
|---|---|---|
| Test | apk_test_… | ask_test_… |
| Live | apk_live_… | ask_live_… |
Nothing else changes — no separate base URL, no separate account.
What you can exercise without a chain #
Two things are worth testing directly, because they are where integrations actually break:
- Your webhook verifier. Sign a body yourself with your endpoint secret and POST it at your own handler. The signed string is
v1:<ts>:<event_id>:<raw_body>— the Webhooks page has a complete Node and PHP verifier. - Your idempotency behaviour. POST the same
Idempotency-Keytwice with the same body (expect a replay) and then with a different body (expect409 idempotency_key_reused). Race two requests on one key and confirm the loser’s429 rate_limitedmakes you retry the same key rather than mint a new one. Then deliver the same webhook event id twice and confirm your order is fulfilled once.
// Produce a valid signature for your own handler, using only node:crypto.
const crypto = require('node:crypto');
const body = JSON.stringify({ id: 'evt_test_1', type: 'payment.confirmed', created: Math.floor(Date.now() / 1000), data: { payment_intent: 'pay_test', merchant_order_id: '1' } });
const event = JSON.parse(body);
const signed = `v1:${event.created}:${event.id}:${body}`;
const sig = crypto.createHmac('sha256', process.env.ANIMICA_WEBHOOK_SECRET).update(signed, 'utf8').digest('hex');
await fetch('http://localhost:3000/webhooks/animica', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Animica-Signature': `v1=${sig}`,
'Animica-Event-ID': event.id,
'Animica-Timestamp': String(event.created),
},
body,
});
A checklist before you go live #
- [ ] A payment reaches your handler and marks exactly one order paid.
- [ ] Delivering the same event twice fulfils the order once.
- [ ] An event with a corrupted signature is rejected with a 400 and changes nothing.
- [ ] An event 10 minutes old is rejected as stale.
- [ ] A retried intent creation with the same key does not create a second intent.
- [ ] Your handler answers 200 to event types it ignores.
- [ ] Amounts are parsed as strings or big integers, never as floats.
Then read Going live.