Test mode
pk_test_ / sk_test_ keys, magic test numbers, scenarios, the fixed code and the test inbox
Test keys let you build and test an integration without sending a real SMS or spending credits. A test key calls the same API with the same request and response shapes, but it only accepts magic test numbers, and the API simulates delivery and verification for them. Nothing is sent, nothing is billed, and the results are deterministic, so you can run them in CI.
Test mode only simulates K-OTP. Before launch, confirm the real path with a few live sends (see Verify production with live sends).
Test keys
Create test keys in the console: open an app, go to API keys, switch to the Test tab and choose New test key.
| Live key | Test key | |
|---|---|---|
| Prefix | pk_, sk_ | pk_test_, sk_test_ |
| Phone numbers | Real numbers (a magic number is rejected) | Magic test numbers only (a real number is rejected) |
| Messages and credits | Sends real messages and spends credits | Sends nothing and spends nothing |
| Scopes | See Scopes | The same rules as live keys |
pk_ allowed origins | 1–20 exact origins | localhost and 127.0.0.1 on any port, plus 0–20 exact origins you register |
| Rate limits | Per key, IP and phone number (key rules apply) | Fixed: 120/1m and 5,000/day per key, for issue, verify and reads each |
| Keys per app | Console limit (20 by default) | Up to 5 active test keys per app, counted separately |
| Where to see results | Usage, issue history, credit ledger | The app's Test inbox (test issues never appear in usage, history or ledger) |
- The API decides the mode from the key prefix. Every issue, verify, status, issues and balance response carries
mode: "live"ormode: "test". - Live and test data are separate. A live key cannot read a test
issueId(and the other way round): it answers404 NOT_FOUND. Idempotency keys are separate too, so reusing one across modes never conflicts. - A
pk_test_key acceptshttp://andhttps://origins onlocalhostand127.0.0.1with any port without registering them, so local development works out of the box. Other origins (for example a preview deployment) must be registered exactly, like for a livepk_key.[::1]and other private addresses are not included. - Test keys cannot have a custom rate limit. A request over a limit returns
429 TOO_MANY_REQUESTSlike a live key, withdata.policy: "platform"anddata.limitnaming the rule:perKey(per minute) orperKeyDaily(per day). Reads (GET /v1/status,/v1/issues,/v1/balance,/v1/credit-ledger) count too, for test keys only. - New test issues also share daily budgets that reset at 00:00 UTC: 10,000 per organization (
data.limit: "perOrgDaily") and a platform-wide budget for all test traffic (data.limit: "testModeDaily"). They are generous on purpose: they only stop runaway loops, such as a test suite stuck retrying. Only new test issues count: an idempotent retry with the same key does not. - Store a
sk_test_key as a secret, like a live key (for example as a CI secret). Revoke a test key the same way as a live key.
curl "https://api.k-otp.dev/v1/issue" \
-H "Authorization: Bearer $KOTP_TEST_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "phoneNumber": "010-0000-0000", "purpose": "login" }'Magic test numbers
The last two digits (xx) choose the scenario. Every region uses the same table.
| Region | Format | Example (00) | Why it is safe |
|---|---|---|---|
| Korea | 010-0000-00xx, +82 10-0000-00xx | 010-0000-0000 | Numbers starting with 010-0 are never assigned to subscribers |
| UK | +44 7700 9000xx | +44 7700 900000 | Ofcom's reserved drama range 07700 900000–900999 |
| US | +1 NXX 555-01xx | +1 201 555-0100 (any area code) | NANP's fictional range 555-0100–0199 |
Numbers are compared after removing everything but digits (+82 10… counts as 010…), so hyphens and spaces do not matter.
Scenarios
xx | Scenario | Issue response | Delivery status (GET /v1/status) | Verify with the fixed code |
|---|---|---|---|---|
00 | Success | 200 | sent after 1 s, delivered after 2 s | verified |
01 | Delivery failed | 200 | sent after 1 s, failed after 3 s | verified (delivery and verification are independent) |
02 | Slow delivery | 200 | queued/sent for 30 s, then delivered | verified |
03 | Unknown result | 200 | stays sent with providerOutcomeAmbiguous: true (unknown result) | verified |
04 | Expired | 200 with expiresAt = issue time + 10 s | delivered | EXPIRED after 10 s |
05 | Wrong code | 200 | delivered | Every code is MISMATCH; the maxAttempts-th is MAX_ATTEMPTS |
06 | Rate limited | 429 TOO_MANY_REQUESTS (perPhone, Retry-After: 60) | — | — |
07 | Insufficient credit | 402 PAYMENT_REQUIRED (data.code: INSUFFICIENT_CREDIT) | — | — |
08 | Trial daily limit | 402 PAYMENT_REQUIRED (data.code: TRIAL_DAILY_LIMIT_EXCEEDED) | — | — |
09 | Service unavailable | 503 SERVICE_UNAVAILABLE (Retry-After: 2) | — | — |
10 | AlimTalk failover | 200 | AlimTalk fails, the SMS failover is delivered after 4 s (see below) | verified |
11–99 | Reserved | Behaves like 00 for now |
- Scenario
10only fails over for AlimTalk delivery (the default channel): withsmsFallback: falsethe delivery isfailedafter 3 s, and withchannel: "sms"it behaves like00. - Error responses use the same envelope and fields as live errors (see Errors), so your error handling is exercised as is.
- Re-issuing for the same number and
purposereplaces the earlier issue (replaced), and idempotency keys behave as in live mode. - Templates are rendered exactly as in live mode, so an unknown template or missing variables still return
400 BAD_REQUEST.
The fixed code
Every magic number is verified with a fixed code: 000000 (a 0 for every digit of the code). Test issues have no random code and the code is never returned by the API, just like in live mode.
curl "https://api.k-otp.dev/v1/verify" \
-H "Authorization: Bearer $KOTP_TEST_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "issueId": "<issueId from the issue response>", "code": "000000" }'Errors only test mode returns
| Status | code | data.code | When |
|---|---|---|---|
| 400 | BAD_REQUEST | TEST_NUMBER_REQUIRED | A test key sent a number that is not a magic test number. |
| 400 | BAD_REQUEST | TEST_NUMBER_IN_LIVE_MODE | A live key sent a magic test number. Nothing is sent. |
Both show up on the first request, so a test key deployed to production (or a magic number left in live code) is caught before anyone waits for an SMS.
Balance and credit ledger
Test keys never read your real wallet.
-
GET /v1/balancereturns a fixed simulated balance that test issues never reduce. Use scenarios07and08to test the out-of-credit paths.{ "appId": "app_...", "walletId": "test:app_...", "walletScope": "test", "organizationId": "...", "balance": 1000000, "currency": "CREDIT", "updatedAt": "2026-10-11T03:00:00.000Z", "promoBalance": 0, "promoNextExpiry": null, "mode": "test" } -
GET /v1/credit-ledgerreturns an empty list (items: [], no next cursor).
Test inbox
Each app has a Test inbox in the console (open the app, then Test inbox). It lists the app's test issues of the last 7 days with the time, magic number, scenario, delivery and verification status, attempts and the fixed code, and it has the magic number table with copy buttons. The inbox is usually about a minute behind the API, and up to 15 minutes while the app is busy; for real-time status call GET /v1/status with your test key.
Recommendations
| Situation | Use |
|---|---|
| Local development, CI integration tests | Test keys and magic numbers: free and deterministic. Keep the sk_test_ key in your CI secrets. |
| Unit tests of an app that uses the SDK | Your own fakes for now. SDK helpers for test mode (magic number constants and an in-memory transport) are coming in a later SDK release. |
| Verifying a production rollout | A live key and a few real sends to your team's phones (uses credits). Magic numbers are rejected in live mode. |
| Rehearsing failure handling | Scenarios 06 (429), 07 (402), 09 (503) and 03 (unknown result). |
Verify production with live sends
The simulator follows the scenarios above, but it cannot reproduce everything a real carrier or messaging provider does. Before you launch, and after changing templates or channels, send a few OTPs with a live key to real phones you own and check delivery and verification end to end. Those sends use credits like any live send.