About clerk-sms-logs
clerk-sms-logs is a skill for investigating the delivery lifecycle of SMS sent by Clerk. It helps developers distinguish Clerk-side blocks, provider failures, carrier delivery issues, and outcomes that remain unconfirmed using Application Logs.
Key features
- 01Explains five SMS lifecycle events
- 02Queries logs through Dashboard or Backend API
- 03Filters by time, phone, user, and trace
- 04Provides failure diagnosis steps
- 05Clarifies event scope and telemetry limits
Use cases
- 01Investigate a missing verification code
- 02Distinguish pre-send rejection from carrier failure
- 03Review a phone number's message timeline
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ clerk/ skills --skill clerk-sms-logsAfter installing, type/clerk-sms-logsin Claude Code to confirm it loaded.
Bundled filesreferences/events.md
Details
From the project READMESMS Logs
Answer one question: what happened to this Clerk-delivered SMS? The sms.*
Application Logs record the delivery lifecycle of every SMS Clerk sends on an
instance's behalf — phone verification codes, OTPs, password-reset codes — so
you can tell a stuck sign-up from a carrier rejection from a Clerk-side block
without guessing.
Read them two ways: in the Clerk Dashboard → Application Logs (filter to
the sms.* event types), or programmatically over the Backend API
(GET /v1/logs) — the same data, scriptable for a repeatable investigation.
They are delivery telemetry about end-user activity, not dashboard actions.
Querying the logs
The Backend API logs endpoints take a secret key (sk_*). The simplest
caller is the Clerk CLI, which injects auth for you:
# SMS lifecycle events in the last 24h, newest first
SINCE=$(( ($(date +%s) - 86400) * 1000 ))
clerk api "/logs?type=sms.*&event_time_after=${SINCE}&limit=20"
# Just failures, same window
clerk api "/logs?type=sms.failed&event_time_after=${SINCE}&limit=20"
# One phone number's SMS history (exact-match payload filter)
# channel=sms keeps WhatsApp off the timeline — see the note below
clerk api "/logs?type=sms.*&payload_filter[phone_number]=%2B14155550100&payload_filter[channel]=sms&event_time_after=${SINCE}"
# Why a number's sends failed — concrete type unlocks reason/raw_error
clerk api "/logs?type=sms.failed&payload_filter[phone_number]=%2B14155550100&event_time_after=${SINCE}&payload_fields=phone_number,reason,raw_error,rejected_before_send"
# Every event for one message, by its trace
clerk api "/logs?type=sms.*&trace_id=<trace_id>&event_time_after=${SINCE}"
Always bound the time range. These logs are high-volume, so start with a
narrow event_time_after (and event_time_before when you know roughly when
the SMS was sent) and widen only if you come up empty — don't scan the whole
retention window to find one message. Keep limit modest (10–50) and page
with the returned cursor rather than raising it. The endpoint queries in
adaptive time windows, so a short or empty page inside your range is normal:
follow starting_after until the response reports no next page.
WhatsApp rides the same sms.* family (one pipeline; the channel payload
field is sms or whatsapp), so a phone-number timeline can mix both
transports. This skill covers SMS — add payload_filter[channel]=sms to scope
to it, or read the channel field to tell them apart.
Or call the Backend API directly:
SINCE=$(( ($(date +%s) - 86400) * 1000 ))
curl -s "https://api.clerk.com/v1/logs?type=sms.failed&event_time_after=${SINCE}&limit=20" \
-H "Authorization: Bearer $CLERK_SECRET_KEY" \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))"
Key query parameters (all optional except where a playbook step needs one):
| Param | Purpose |
|---|---|
type | Event type: concrete (sms.failed) or trailing wildcard (sms.*). Required to use payload_filter. |
payload_filter[<field>] | Exact match on a payload field, e.g. payload_filter[user_id]=user_123. URL-encode values (a + in an E.164 number becomes %2B). |
trace_id | Correlate every event of one message. |
event_time_after / event_time_before | Unix ms bounds. Set at least event_time_after on every query — see the note above. |
limit, starting_after, ending_before | Cursor pagination. |
The list response omits the decoded payload by default; add payload_fields
(comma-separated leaf paths) to include specific fields, or fetch one row in
full with GET /v1/logs/{event_time_ms}:{event_id}.
reason, raw_error, and rejected_before_send exist only on the failure
events, so they are selectable (and filterable) only when type is the
concrete sms.failed or sms.undeliverable. Under the sms.* wildcard,
payload_fields and payload_filter are validated against the intersection
of all five schemas — the common fields (phone_number, user_id,
phone_number_id, slug, verification_id, source_type, purpose,
channel) — and asking for reason there is a 422. Use sms.* to see a
number's whole timeline; switch to type=sms.failed to read why it failed.
Discover the exact allowed fields for any type with
GET /v1/logs/schemas?type=sms.failed (or ?type=sms.* for the intersection).
These
logsendpoints are newer and require the feature enabled on the instance; if a call 404s or returns nothing, confirm SMS logs are on for the instance before treating an empty result as "no such SMS".
The five lifecycle events
One SMS moves through this family. Each event is emitted once, when the mapped
state first changes — so a fast delivery may skip straight to sms.delivered
without a visible intermediate.
| Event | Meaning | Terminal? |
|---|---|---|
sms.accepted | The delivery provider accepted the handoff. Not proof it reached the phone. | No |
sms.delivered | The carrier confirmed delivery to the handset. | Yes (success) |
sms.failed | The send stopped before handoff, or the provider rejected it outright. Carries a reason. | Yes (failure) |
sms.undeliverable | The carrier reported it could not deliver after the provider accepted it. Carries a reason. | Yes (failure) |
sms.unconfirmed | The provider explicitly reported an unknown/unconfirmed outcome. No delivery evidence either way. | No — a later delivered/undeliverable can still supersede it |
The mental model: accepted → delivered is the happy path. failed means it
never got out (or was refused at the door); undeliverable means it got out
but the carrier bounced it; unconfirmed means nobody knows.
Debugging playbook
"The user never received the code." Find the message — query sms.*
filtered by the phone number or user id (payload_filter[phone_number] /
payload_filter[user_id], plus payload_filter[channel]=sms to keep WhatsApp
out, per the section above). Then read the latest event for that message:
- No
sms.*event at all → the logs don't establish that a send was attempted. Rule out a gap in the query first: confirm SMS logs are enabled for the instance, that your time bounds cover the send, and that you paged the cursor to the end (these logs are best-effort — see Scope). Once the logs are trustworthy and still empty, the cause is usually upstream: the verification wasn't created, or the number was blocked before the SMS pipeline (check Protect / bot-detection events). It is not an SMS delivery problem. sms.failed→ read thereason. Ifrejected_before_sendis present, Clerk stopped it (country block, monthly limit, rate limit) — the fix is on your configuration, not the carrier. Otherwise the provider refused it; see the reason table.sms.acceptedbut nosms.delivered→ it left Clerk and the carrier never confirmed. Give it a moment (delivery receipts lag), then treat a lasting gap as a carrier/handset issue for that number.sms.undeliverable→ the carrier bounced it after accepting. Read thereason;destination_unreachable/invalid_phone_numberpoint at the number itself.sms.unconfirmed→ no delivery signal exists yet. It isn't terminal: a latersms.deliveredorsms.undeliverablecan still supersede it, so re-query the trace before concluding. Don't infer success or failure; if a definitive event hasn't arrived and the user didn't get it, have them retry.
"SMS to is broken." Look for sms.failed with
reason = country_not_supported (Clerk blocks the country) or
restricted_destination (the provider won't send there). A spike of
reason = rate_limited with rejected_before_send set is Clerk's own
per-number/prefix throttle — expected under a pumping attack, not an outage.
Reading a failure
sms.failed and sms.undeliverable carry a normalized reason from a fixed
set (the filterable, stable contract) plus an optional raw_error (the
provider's own words, when it reported any — useful for a specific case, but
provider-specific and unstable, so never filter on it).
The full reason vocabulary and what each value means — including which reasons only ever appear on Clerk-side pre-send rejections — is in references/events.md.
Correlating one message's lifecycle
Every event for a single SMS shares the same trace. When a message has
several events (e.g. accepted then undeliverable), they correlate under one
trace id even though their subjects may differ. Use the trace to assemble the
timeline for one message rather than reading events in isolation.
Scope and guarantees
- Clerk-delivered SMS only. If you bring your own SMS provider
(
delivered_by_clerk = false), these events do not describe your sends. - Payloads never contain the message body, the verification code, or the
provider's identity. Debug from
reason, timestamps, and the phone/user ids — not from message contents that aren't there. - Best-effort telemetry. Like all Application Logs, delivery of these events is not guaranteed; a missing event is weak evidence. Don't build application logic that depends on every SMS event arriving — for delivery state your app must act on, use the verification's own status.
protect.sms.*(Clerk Protect's anti-abuse blocks) is a separate family; a Clerk-side block shows up there, not assms.failed.
Full event/reason/payload reference: references/events.md.