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

KeyWhere it livesWhat 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 publishWidget 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.statusMeaningShow the user
queuedIn Auskal, being handed to a providerSending…
acceptedA provider took it. Not proof of deliveryWaiting for the carrier
sentHanded to the mobile networkWaiting for delivery confirmation
deliveredThe carrier confirmed the handset received it. This is the only status that means deliveredDelivered
undeliverablePhone off, out of coverage or not in serviceCheck the number; offer resend or WhatsApp
rejectedRefused by the provider or network. Auskal fails over to the next route once, automaticallySending another way…
expired / failedNot delivered in time / could not be sentOffer resend
unknownNo delivery report after 15 minutes. Never treated as delivered; a late report still updates itOffer 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:

These use the X-Auskal-API-Key header and return errors as {"detail": "…"}. Use /v1/verifications for anything new.