Integration guide
Send a code, check it, and confirm the result on your server. Base URL https://sms.auskal.tech. The full OpenAPI 3.1 reference is at /docs when published, and always in the repository as docs/openapi.json.
Keys
| Key | Where it lives | What it can do |
|---|---|---|
ak_live_… / ak_test_… | Your server only (environment variable) | Start, check, resend, cancel and read verifications; introspect tokens |
pk_live_… / pk_test_… | Browser, safe to publish | Widget calls only, from origins you allow-list |
Send secret keys as Authorization: Bearer ak_…. Test keys always use the sandbox: no real messages, no cost, and the code comes back as sandbox_code. Your keys are shown once when your account is created; Auskal stores only a hash of each.
Server flow
POST /v1/verifications
Authorization: Bearer ak_live_…
Idempotency-Key: 3f1c9a… (recommended)
{"to": "+67570123456", "channel": "sms", "purpose": "login"}
→ 201 {"id": "otp_…", "object": "verification", "status": "pending",
"to": "+675 •••• 3456", "channel": "sms", "purpose": "login", "country": "PG",
"delivery": {"status": "accepted", "detail": "…", "attempt": 1, "error_code": null},
"attempts_left": 5, "resends_left": 3, "expires_at": "…", "resend_after": "…", "livemode": true}
POST /v1/verifications/otp_…/check
{"code": "482915"}
→ 200 {"id": "otp_…", "valid": true, "status": "verified", "attempts_left": 4, "verification_token": "…"}
→ 200 {"id": "otp_…", "valid": false, "status": "pending", "attempts_left": 3} (wrong code)
GET /v1/verifications/otp_… status, including delivery
POST /v1/verifications/otp_…/resend fresh code; {"channel": "whatsapp"} switches channel
POST /v1/verifications/otp_…/cancel the code can no longer be used
purpose is one of signup, login, reset, checkout, step_up, change_phone or transaction. channel is sms, whatsapp, voice or email (for email, to is the email address). A national number works with "country": "PG". After 5 wrong codes the verification is locked. Checking a finished verification answers 409; an expired one, 410.
Retries
Send an Idempotency-Key header on every POST, and after a timeout retry with the same key. You get the first response back, and no second code is sent. Keys are kept for 24 hours.
Browser widget
<div id="otp"></div>
<script src="https://sms.auskal.tech/assets/countries.js"></script>
<script src="https://sms.auskal.tech/assets/auskal-otp.js"></script>
<script>
AuskalOTP.mount("#otp", {
publishableKey: "pk_live_…", apiBase: "https://sms.auskal.tech", purpose: "login",
channels: ["sms", "whatsapp"], defaultCountry: "PG",
onVerified: ({ verification_token }) =>
fetch("/session", { method: "POST", body: JSON.stringify({ verification_token }) })
});
</script>
The browser result can be faked, so your server must confirm the token once:
POST /v1/tokens/introspect
Authorization: Bearer ak_live_…
{"token": "…", "consume": true}
→ {"valid": true, "request_id": "otp_…", "purpose": "login", "destination": "+675 •••• 3456"}
A token is valid for 10 minutes and can be consumed once. A second call returns {"valid": false, "reason": "already_used"}.
Delivery status
| delivery.status | Meaning | Show the user |
|---|---|---|
queued | In Auskal, being handed to a provider | Sending… |
accepted | A provider took it. Not proof of delivery | Waiting for the carrier |
sent | Handed to the mobile network | Waiting for delivery confirmation |
delivered | The carrier confirmed the handset received it. This is the only status that means delivered | Delivered |
undeliverable | Phone off, out of coverage or not in service | Check the number; offer resend or WhatsApp |
rejected | Refused by the provider or network. Auskal fails over to the next route once, automatically | Sending another way… |
expired / failed | Not delivered in time / could not be sent | Offer resend |
unknown | No delivery report after 15 minutes. Never treated as delivered; a late report still updates it | Offer resend |
Webhooks
Set a webhook URL on your account to receive these events: otp.created, otp.accepted, otp.delivered, otp.delivery_failed, otp.failover, otp.undeliverable, otp.delivery_unknown, otp.resent, otp.verified, otp.locked, otp.expired, otp.canceled and fraud.prefix_blocked.
Events for one verification arrive in order, starting with otp.created, and each event is delivered once. De-duplicate on X-Auskal-Event-Id anyway.
Each POST carries X-Auskal-Signature: t=<unix>,v1=<hex>: an HMAC-SHA256 of "<t>." + raw body, made with your webhook secret. Reject anything older than 5 minutes, and answer with a 2xx within 5 seconds. Failed deliveries are retried with back-off, up to 8 times.
expected = hmac.new(secret, f"{t}.".encode() + raw_body, sha256).hexdigest()
hmac.compare_digest(expected, v1)
Errors
Errors return {"error": {"code": "…", "message": "…", "request_id": "req_…"}}, and every response has an X-Request-Id header. Quote it when you contact support. The message is safe to show users. Common codes: invalid_phone, country_not_allowed, rate_limited, resend_too_soon, prefix_blocked, verification_not_pending, verification_expired, quota_exceeded, spend_cap_reached, daily_sms_cap_reached, sending_paused, no_route.
Limits
Every 429 carries Retry-After in seconds. Daily spend and SMS caps reset at 00:00 UTC (10:00 Port Moresby), and the monthly quota resets on the 1st.
If many codes go to one number range in a short time (possible SMS pumping), live codes to that range are paused for your account for an hour, and you receive fraud.prefix_blocked. Contact support if the traffic is genuine.
Sandbox numbers
With a test key, numbers ending 0000 are undeliverable, 1111 are rejected, 2222 never get a delivery report (unknown) and 3333 expire. Every other valid mobile number is delivered.
v1 API (existing integrations)
Integrations built for the original sms.auskal.tech API keep working unchanged:
POST /v1/otp/sendwith{"phone", "purpose"}POST /v1/otp/verifywith{"phone", "code"}, which returns{"verified": true}or a 400GET /v1/otp/status/{request_id}
These use the X-Auskal-API-Key header and return errors as {"detail": "…"}. Use /v1/verifications for anything new.