CLI
sendoka — a small command-line loop over the API: one-off sends, tailing
messages as they are created, forwarding webhook deliveries to localhost, and
firing a synthetic signed event at one of your endpoints.
It is deliberately minimal. Five commands, listed in full below — there is no key management, no domain management, and no interactive login. Anything not on this page is not implemented; use the dashboard or the REST API for the rest.
Install
npm install -g sendoka
Requires Node 20+. Source: packages/cli/.
Configure
Every command authenticates with an API key and calls /api/v1/*.
export SENDOKA_API_KEY=sok_test_...
# For `listen` only: the endpoint's signing secret (whsec_...), so forwarded
# deliveries are re-signed and your handler's verification runs. --secret
# works too, but an env var stays out of your shell history and `ps`.
export SENDOKA_WEBHOOK_SECRET=whsec_...
# Optional. Defaults to https://www.sendoka.com.
export SENDOKA_BASE_URL=https://www.sendoka.com
The key needs the scope each command's endpoint needs:
| Command | Scope |
|---|---|
send email / send sms |
send:email / send:sms |
logs tail |
read:messages |
listen |
read:webhooks |
events trigger |
write:webhooks |
A full-access key — no scope list, which is what the dashboard creates — has
all of them. There is no session-cookie mode: listen and events trigger used to read
SENDOKA_SESSION_COOKIE and call /api/internal/*, but they sent the cookie
under the name NextAuth uses over plain http (next-auth.session-token), and
the https host reads __Secure-next-auth.session-token — so both answered 401
against www.sendoka.com. SENDOKA_SESSION_COOKIE is no longer read.
There is no credentials file and nothing is written to ~. Every command reads
the environment on each run.
The CLI spends your org's rate limit
Every request the CLI makes is an ordinary /api/v1 request, so it counts
against the org-wide rate limit — 60 requests a
minute on Free — which your production traffic draws on too, and which test
keys share with live ones. The two polling commands run for as long as you
leave them open:
| Command | Requests at the default --interval 5 |
|---|---|
listen |
12/min — one list read per poll, more only when over 50 deliveries arrive between polls |
logs tail |
24/min — one read per channel per poll (12/min with --channel) |
Both together are 36/min, well over half of a Free org's budget, before your
application sends anything. On a Free org, raise --interval (--interval 15
cuts either one to a third), or give the CLI its own key with a per-key limit
(rateLimitPerMinute at creation): that
bucket is checked first, and a request it refuses never reaches the org's. On a 429 both commands wait out the
response's Retry-After and pick up where they left off; nothing is skipped.
Commands
sendoka send email
POST /api/v1/emails with the flags below and prints the JSON response.
sendoka send email \
--from hello@yourdomain.com \
--to user@example.com \
--subject "Hello" \
--html "<p>From the CLI</p>"
| Flag | Required | Description |
|---|---|---|
--from <email> |
yes | Sender on a verified domain |
--to <list> |
yes | Recipient. Comma-separated for several — the flag is not repeatable |
--subject <s> |
unless --template supplies one |
Subject line |
--html <html> |
one of | HTML body |
--text <text> |
one of | Plain-text body |
--template <slug> |
one of | Template slug |
--variables <json> |
— | Template variables as a JSON object string, e.g. --variables '{"name":"Mira"}'. Invalid JSON fails locally before the request |
Only --from and --to are checked by the CLI; everything else is validated by
the API, so a missing subject comes back as a 422 VALIDATION_ERROR body rather
than a local message.
Not implemented here: attachments, tags, scheduling, and Idempotency-Key. Send
those with an HTTP client against POST /v1/emails.
sendoka send sms
POST /api/v1/sms.
sendoka send sms --from +15551234567 --to +15559876543 --body "Test"
| Flag | Required | Description |
|---|---|---|
--to <e164> |
yes | One recipient in E.164 |
--body <text> |
yes | Message text |
--from <sender> |
in practice | Registered number or alphanumeric sender ID. The CLI does not check it; live sends without a registered sender fail with SENDER_NOT_REGISTERED |
--template <slug> |
— | Template slug |
--variables <json> |
— | JSON object string, as above |
sendoka logs tail
Prints each message as it is created, one line per message. Polls
GET /api/v1/emails and GET /api/v1/sms
with created_after, starting from the newest message that exists when the
command starts — nothing older is printed.
sendoka logs tail # both channels, polling every 5s
sendoka logs tail --channel sms # one channel
sendoka logs tail --interval 15 # seconds between polls
| Flag | Required | Description |
|---|---|---|
--channel <email|sms> |
— | Tail one channel. Default: both |
--interval <seconds> |
— | Poll interval, default 5 |
Output:
Tailing new email + sms messages. Ctrl+C to stop.
[2026-09-23T10:14:22.113Z] email sent msg_01HN... hello@acme.com → customer@example.com "Welcome"
[2026-09-23T10:14:25.902Z] sms sent msg_01HP... +15551234567 → +15559876543
What it shows and what it does not:
- The key's environment only. The list routes filter on the calling key's
environment, so a
sok_test_*key tails test sends and asok_live_*key live ones. The same holds for a tenant-bound key and its tenant. - The status at first sight. A message is printed once, with the status it
had on the poll that found it — usually
sent,queuedorscheduled. Later transitions (delivered,bounced) are not re-printed; subscribe a webhook and uselistenfor those. - The watermark is the server's clock, not yours: each poll asks for rows created at or after the newest one already printed, minus a five-second overlap so a send that commits slightly out of order is still caught. Duplicates from the overlap are dropped by id.
- More than 1,000 new messages on one channel between two polls prints the newest 1,000 and a warning on stderr.
A bad key or a missing read:messages scope exits 1 at startup; errors on
later polls are printed and the loop continues — after a 429, once the
response's Retry-After has passed rather than after the interval. A poll that
fails prints nothing and moves no watermark, so its messages come out on the
next one.
--days belonged to the previous version, which polled /v1/activity and
printed daily aggregate counts rather than messages. It is now ignored, with a
note on stderr; call GET /api/v1/activity (read:events) directly for the
aggregates.
sendoka listen
Watches one webhook endpoint's deliveries and re-POSTs each new one to a local URL, signed with the endpoint's secret, so a local handler receives real events — signature verification included — without a public tunnel.
export SENDOKA_WEBHOOK_SECRET=whsec_... # the endpoint's secret
sendoka listen --endpoint whk_01HN... --forward-to http://localhost:3001/hooks
sendoka listen --endpoint whk_01HN... --forward-to http://localhost:3001/hooks --interval 10
| Flag | Required | Description |
|---|---|---|
--endpoint <whk_id> |
yes | The webhook endpoint whose deliveries are forwarded |
--forward-to <url> |
yes | Absolute http(s) URL each delivery is POSTed to |
--secret <whsec_...> |
— | The endpoint's signing secret. Falls back to SENDOKA_WEBHOOK_SECRET |
--interval <seconds> |
— | Poll interval, default 5 |
What it does:
- At startup, reads the newest page of
GET /api/v1/webhooks/{id}/deliveriesand remembers it. Nothing that existed before the command started is forwarded, and a restart does not replay anything. A bad key, a missingread:webhooksscope or an unknown endpoint exits1here. - Every interval, reads the list again with
?include=payload(newest first, followingnext_cursoruntil it reaches a delivery it has already seen), so the payloads arrive with the list — one request per page, not one per delivery. Against a server withoutinclude=payloadit falls back toGET /api/v1/webhooks/{id}/deliveries/{deliveryId}per delivery. - POSTs it to
--forward-to, oldest first, exactly as production would have sent it: the body is the stored payload plusdelivery_id, withX-Sendoka-Signature,X-Sendoka-Timestamp,X-Sendoka-Signature-V2,X-Sendoka-Event,X-Sendoka-Delivery-Idand, where the payload has them,X-Sendoka-Environment/X-Sendoka-Tenant-Id— plusX-Sendoka-Forwarded: cli. - Prints
[created_at] event whd_... → <your handler's status>.
Signing. The signatures are computed from the secret you supply, with the
same scheme the server uses (HMAC-SHA256 over the body, and over
${timestamp}.${body} for V2), so the SDK's verifyWebhookSignature and the
checks in verify-webhooks.md accept them
unchanged. X-Sendoka-Timestamp is the moment of forwarding, so the five-minute
replay window passes however old the original delivery is. The API never
returns an endpoint's secret after creation — use the whsec_... from
POST /v1/webhooks or from the last rotation. With no secret the CLI prints a
warning and forwards without signature headers; a handler that verifies
will reject those, which is the honest outcome.
Every delivery row counts: real events, events trigger test fires and
replays (each of which creates a new row). Retries of an existing row do not
create one and are not forwarded again. The endpoint's own URL still receives
every delivery as usual — listen reads deliveries, it does not intercept
them — so a dedicated development endpoint keeps your production receiver out
of the loop. Point that endpoint at a public URL that answers 2xx: its
deliveries are retried like any other, and ten exhausted in a row auto-disable
the endpoint, after which no new deliveries are created for listen to see. A
forward that fails
— your handler is not running — is reported once on stderr and not retried;
replay the delivery to send it again.
A delivery whose payload could not be read is different: it is not marked
seen, so the next poll tries it again, and the deliveries behind it wait so
they still arrive in order. A 429 pauses the loop for the response's
Retry-After (30 seconds if it has none) without counting as a failure; any
other error is retried on the next two polls and then skipped with a warning.
A delivery that no longer exists (404) is skipped at once.
sendoka events trigger
Fires a synthetic signed delivery at one of your endpoints —
POST /api/v1/webhooks/{id}/test-fire with the API key (write:webhooks),
the same operation as the dashboard's Send test event button.
sendoka events trigger message.bounced --endpoint whk_01HN...
# --event is accepted as an equivalent to the positional form
sendoka events trigger --event message.bounced --endpoint whk_01HN...
# --data is merged over the synthetic data object
sendoka events trigger message.bounced --endpoint whk_01HN... --data '{"channel":"sms"}'
| Flag | Required | Description |
|---|---|---|
<event> / --event <event> |
yes | An event Sendoka emits. --event wins if both are given |
--endpoint <whk_id> |
yes | The endpoint to fire at |
--data <json> |
— | A JSON object merged over the synthetic data. Anything else fails locally before the request |
The event name must be one Sendoka actually emits (see
events.md); * is a subscription wildcard, not a
deliverable event, and is rejected with 422 VALIDATION_ERROR. The payload
carries data.test: true and a synthetic message_id, so a handler can tell it
from a real event. The response is the delivery row:
{ id: "whd_...", status: "queued", payload: {...} }.
Refusals use the standard /v1 error envelope and the CLI prints
HTTP <status>: <message> (<CODE>): 409 ENDPOINT_DISABLED for a disabled
endpoint, 404 NOT_FOUND for one outside the key's project or tenant, and
429 TEST_FIRE_RATE_LIMITED past 20 events per minute per endpoint — a
budget shared with the dashboard button.
Exit codes
0— success1— the command threw (HTTP error, missing env var, bad--variables/--dataJSON, bad--interval)2— unknown command; usage is printed
There are no distinct codes per HTTP status: an auth failure, a 422 and a 500
all exit 1 with HTTP <status>: <message> (<CODE>) on stderr. Branch on the
code in parentheses, or call the API directly if a script needs more.
Recipes
Send a test email from CI
sendoka send email --from "ci@yourdomain.com" --to "qa@yourdomain.com" \
--subject "Build $GITHUB_SHA passed" \
--html "<p><a href='$BUILD_URL'>$BUILD_URL</a></p>"
Point SENDOKA_API_KEY at a sok_test_* key unless the mail is genuinely meant
to arrive — test keys never reach a provider and are never billed.
Local webhook dev loop
# Terminal 1 — your handler, listening on :3001
node my-webhook-handler.js
# Terminal 2 — forwarder, re-signing with the endpoint's secret
export SENDOKA_WEBHOOK_SECRET=whsec_...
sendoka listen --endpoint whk_01HN... --forward-to http://localhost:3001/hooks
# Terminal 3 — cause an event: a synthetic one...
sendoka events trigger message.delivered --endpoint whk_01HN...
# ...or a real send
sendoka send email --from ... --to ... --subject test --html '<p>hi</p>'
The delivery reaches your handler within about one poll interval, signed, so the handler can keep its verification on. A real send only produces a delivery if the endpoint subscribes to that event.