K-OTP

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 keyTest key
Prefixpk_, sk_pk_test_, sk_test_
Phone numbersReal numbers (a magic number is rejected)Magic test numbers only (a real number is rejected)
Messages and creditsSends real messages and spends creditsSends nothing and spends nothing
ScopesSee ScopesThe same rules as live keys
pk_ allowed origins1–20 exact originslocalhost and 127.0.0.1 on any port, plus 0–20 exact origins you register
Rate limitsPer 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 appConsole limit (20 by default)Up to 5 active test keys per app, counted separately
Where to see resultsUsage, issue history, credit ledgerThe 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" or mode: "test".
  • Live and test data are separate. A live key cannot read a test issueId (and the other way round): it answers 404 NOT_FOUND. Idempotency keys are separate too, so reusing one across modes never conflicts.
  • A pk_test_ key accepts http:// and https:// origins on localhost and 127.0.0.1 with 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 live pk_ 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_REQUESTS like a live key, with data.policy: "platform" and data.limit naming the rule: perKey (per minute) or perKeyDaily (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.

RegionFormatExample (00)Why it is safe
Korea010-0000-00xx, +82 10-0000-00xx010-0000-0000Numbers starting with 010-0 are never assigned to subscribers
UK+44 7700 9000xx+44 7700 900000Ofcom'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

xxScenarioIssue responseDelivery status (GET /v1/status)Verify with the fixed code
00Success200sent after 1 s, delivered after 2 sverified
01Delivery failed200sent after 1 s, failed after 3 sverified (delivery and verification are independent)
02Slow delivery200queued/sent for 30 s, then deliveredverified
03Unknown result200stays sent with providerOutcomeAmbiguous: true (unknown result)verified
04Expired200 with expiresAt = issue time + 10 sdeliveredEXPIRED after 10 s
05Wrong code200deliveredEvery code is MISMATCH; the maxAttempts-th is MAX_ATTEMPTS
06Rate limited429 TOO_MANY_REQUESTS (perPhone, Retry-After: 60)——
07Insufficient credit402 PAYMENT_REQUIRED (data.code: INSUFFICIENT_CREDIT)——
08Trial daily limit402 PAYMENT_REQUIRED (data.code: TRIAL_DAILY_LIMIT_EXCEEDED)——
09Service unavailable503 SERVICE_UNAVAILABLE (Retry-After: 2)——
10AlimTalk failover200AlimTalk fails, the SMS failover is delivered after 4 s (see below)verified
11–99ReservedBehaves like 00 for now
  • Scenario 10 only fails over for AlimTalk delivery (the default channel): with smsFallback: false the delivery is failed after 3 s, and with channel: "sms" it behaves like 00.
  • 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 purpose replaces 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

Statuscodedata.codeWhen
400BAD_REQUESTTEST_NUMBER_REQUIREDA test key sent a number that is not a magic test number.
400BAD_REQUESTTEST_NUMBER_IN_LIVE_MODEA 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/balance returns a fixed simulated balance that test issues never reduce. Use scenarios 07 and 08 to 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-ledger returns 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

SituationUse
Local development, CI integration testsTest keys and magic numbers: free and deterministic. Keep the sk_test_ key in your CI secrets.
Unit tests of an app that uses the SDKYour 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 rolloutA live key and a few real sends to your team's phones (uses credits). Magic numbers are rejected in live mode.
Rehearsing failure handlingScenarios 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.

On this page