The channel secret is part of the webhook URL, so treat the entire URL like a secret. The URL identifies the channel, so the notification reaches that channel's subscribers.
CHANNEL_API_KEY='smk_your_channel_secret'
curl 'https://api.smokesignal.sh/api/v1/notifications' \
--request POST \
--header "Authorization: Bearer $CHANNEL_API_KEY" \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 78cc7a79-2ab8-4a24-a85c-1973c9c846fa' \
--data '{
"title": "Approval needed",
"body": "The release agent is waiting for your decision.",
"url": "https://example.com/runs/982",
"ttl_seconds": 900
}'
Shared contract
Request and response
The webhook and API accept and return the same shapes.
Payload schema
Send only these fields. Omit optional fields you do not need.
Notification payload fields
Field
Type
Presence
Description
title
string
Required
Notification title. From 1 through 80 Unicode characters.
body
string
Optional
Supporting text. Up to 240 Unicode characters.
url
string
Optional
Absolute HTTPS URL opened on tap. Maximum 2,048 bytes; no user information.
ttl_seconds
integer
Optional
Delivery lifetime from 60 through 86,400 seconds. Defaults to 3,600.
to_inbox
boolean
Optional
True additionally keeps the notification readable in the app for 30 days, adding a flat surcharge to the per-subscriber charge shown on the send form. The push itself is unchanged. Defaults to false.
Payload example
{
"title": "Deploy complete",
"body": "Production is running version 1.8.0.",
"url": "https://example.com/deployments/418",
"ttl_seconds": 3600
}
Response schema
A successful send returns 201 Created.
Successful notification response fields
Field
Type
Presence
Description
message_id
string
Always
Opaque identifier for the accepted notification.
logical_audience
integer
Always
Subscribers of the target channel this send can actually reach. Zero means nobody was reachable - the channel is empty, or its subscribers have not turned notifications on - and the send is dropped, delivered to nobody, and charged nothing.
charged_credits
integer
Always
Credits charged when the notification was accepted.
free_credits_consumed
integer
Always
How much of the charge was covered by the weekly free allowance.
paid_credits_charged
integer
Always
How much of the charge was taken from the paid balance.
resulting_balance
integer
Always
Credit balance after the charge.
dropped
boolean
Always
True when no subscriber of the channel could be reached - nobody subscribed, or nobody with notifications turned on. The send was accepted, nothing was delivered, and nothing was charged. Treat true as a failure your operator must see, never as success.
accepted_at
string
Always
RFC 3339 timestamp when Smoke Signal accepted the send.
expires_at
string
Always
RFC 3339 timestamp when delivery is no longer allowed.
to_inbox
boolean
Always
True when this send is saved to each subscriber's inbox.
inbox_until
string
When to_inbox
RFC 3339 timestamp when the entry leaves every subscriber's inbox. Present exactly when to_inbox is true.
A Decision sends a question with two to four choices and waits for a person to pick one. Decisions are API key only: there is no webhook route, because fire-and-forget delivery has no way to consume the answer.
Send only these fields. Unknown fields are rejected, not ignored.
Decision request fields
Field
Type
Presence
Description
question
string
Required
The decision to make. From 1 through 200 Unicode characters.
detail
string
Optional
Supporting context shown under the question. Up to 500 Unicode characters.
actions
array
Required
Two through four choices. Each id is 1 to 32 characters of a-z, 0-9, underscore or hyphen and unique within the Decision; each label is 1 through 40 characters.
ttl_seconds
integer
Optional
Answer window from 60 through 86,400 seconds. Defaults to 3,600.
Decision response
201 when the Decision is on its way. 200 when it reached nobody.
Decision response fields
Field
Type
Presence
Description
decision_id
string
On 201
Identifier used to poll for the reply and to cancel the Decision.
expires_at
string
On 201
RFC 3339 timestamp after which no answer is accepted.
status
string
On 200
The literal "dropped". A 200 carries no decision_id: no subscriber of the channel could be reached, so there is nothing to wait for. Treat it as a failure, never as success.
reason
string
On 200
Why nothing was sent. Currently always "no_subscribers".
charged
integer
Always
Credits charged for the Decision. Zero when it was dropped.
Wait for the answer
The claim endpoint is a long poll, not an open connection. Each call waits up to 25 seconds. Stop at the Decision's expiry and pause for one second after each 202 before polling again. Use ?wait= for a shorter poll, from 0 through 25 seconds. Claiming needs no Idempotency-Key.
# Match this deadline to ttl_seconds used when creating the Decision.
deadline=$(( $(date +%s) + 900 ))
code=202
# 200 carries the answer, 202 means keep waiting, 410 means the Decision is over.
while [ "$code" = 202 ] && [ "$(date +%s)" -lt "$deadline" ]; do
code=$(curl -sS -o reply.json -w '%{http_code}' \
"https://api.smokesignal.sh/api/v1/decisions/$DECISION_ID/reply/claim?wait=25" \
--request POST --connect-timeout 5 --max-time 30 \
--header "Authorization: Bearer $CHANNEL_API_KEY") || {
cat reply.json 2>/dev/null || true
exit 1
}
if [ "$code" = 202 ]; then sleep 1; fi
done
if [ "$code" != 200 ]; then
cat reply.json 2>/dev/null || true
echo "Decision timed out or ended without an answer." >&2
exit 1
fi
jq -r .action_id reply.json
Claim response
200 carries the answer, 202 means keep polling, and 410 means the Decision was answered elsewhere, expired, or was cancelled.
Claim response fields
Field
Type
Presence
Description
action_id
string
On 200
The id of the choice the subscriber picked. The answer is yours once claimed.
replied_at
string
On 200
RFC 3339 timestamp of the answer.
status
string
On 202
The literal "pending". The wait elapsed with no answer yet. Pause before polling again.
expires_at
string
On 202
RFC 3339 timestamp after which polling returns 410 instead.
Cancel a Decision you no longer need an answer to. Returns 204, and any waiting poll then sees 410.
Custom bridge
Remote bridge
Remote lets the app start, steer, and interrupt agent tasks on a paired machine. Claude Code and Codex use the maintained bridges at github.com/smoke-signal-app/agent-plugin. For another agent, choose Custom and follow the same pair, poll, report loop.
# Remote pairing codes have the form RXXXX-XXXXX.
# Normalize the app code: uppercase it, remove the hyphen and ASCII whitespace,
# fold O to 0, and fold I or L to 1.
# DERIVED = PBKDF2-HMAC-SHA256(
# password=NORMALIZED_CODE,
# salt="smoke-signal-remote-pairing-v2\0PBKDF2-SHA256",
# iterations=600000,
# length=48)
# PAIRING_LOCATOR = base64url(DERIVED[0:32])
# PAIRING_SECRET = DERIVED[32:48]
# Never send PAIRING_CODE or PAIRING_SECRET to the relay.
# HOST_KEY_PROOF = base64url(HMAC-SHA256(
# PAIRING_SECRET, "smoke-signal-remote-pairing-v2\0host\0" + HOST_PUBLIC_KEY))
# Verify the returned Device key before saving it:
# DEVICE_KEY_PROOF = base64url(HMAC-SHA256(PAIRING_SECRET,
# "smoke-signal-remote-pairing-v2\0device\0" + ACCOUNT_DEVICE_ID + "\0" +
# KEY_VERSION + "\0" + DEVICE_PUBLIC_KEY))
curl 'https://api.smokesignal.sh/api/v1/remote-pairing/exchange' \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"pairing_locator": "BASE64URL_DERIVED_LOCATOR",
"host_public_key": "BASE64URL_UNCOMPRESSED_P256_PUBLIC_KEY",
"host_key_proof": "BASE64URL_HMAC_SHA256"
}'
Pair the machine
Generate a P-256 key pair locally. The operator chooses Custom, names the machine, and gives your bridge the short RXXXX-XXXXX pairing code. Derive the locator and proof key locally. Send only the locator and Host-key proof to the relay, authenticate the returned Device key, then save the host_key. Keep the pairing code, proof key, and both private keys local.
Pairing exchange request fields
Field
Type
Presence
Description
pairing_locator
string
Required
Unpadded base64url of the first 32 derived bytes. The pairing expires after 10 minutes.
host_public_key
string
Required
Unpadded base64url P-256 public key. Generate it locally and never send the private key.
host_key_proof
string
Required
Base64url HMAC-SHA256 proof of host_public_key using the final 16 derived bytes.
Pairing exchange response fields
Field
Type
Presence
Description
host_id
string
Always
Identifier of the paired host.
host_key
string
Always
smb_ bearer key returned once for bridge routes. Store it like a secret.
origin
string
Always
API origin the bridge should call from now on.
name
string
Always
Operator-chosen display name for this machine.
agent_kind
string
Always
Integration this pairing drives: claude-code, codex, or custom.
device
object
Always
The paired Device id, key version, P-256 public key, and key_proof. Verify the proof exactly as shown above before saving the pairing.
Start the bridge
Authenticate bridge routes with Bearer smb_.... Open a daemon generation with POST /api/v1/remote-host/sessions, then send its host_epoch as X-Smoke-Signal-Host-Epoch. Heartbeat every 30 seconds. Stop on 423; a newer daemon owns the host.
Fetch GET /api/v1/remote-host/devices, then publish independently encrypted workspace and task-form projections for each Device with PUT /api/v1/remote-host/workspaces and PUT /api/v1/remote-host/task-form. A task form may have up to 16 select, input, or textarea fields. Reuse the reference bridge's versioned encryption envelope exactly; the relay must never receive aliases, prompts, transcripts, form values, or session titles in plaintext.
HOST_KEY='smb_your_host_key'
# One long-poll cycle: claim, run, ack. Repeat with a pause and a shutdown condition.
curl 'https://api.smokesignal.sh/api/v1/remote-host/commands/claim?wait=25' \
--header "Authorization: Bearer $HOST_KEY" \
--header 'X-Smoke-Signal-Host-Epoch: 3'
# 200 -> {"command": {"id", "task_id", "kind", "payload", "lease_generation"}}
# 204 -> nothing queued; pause before polling again
curl "https://api.smokesignal.sh/api/v1/remote-host/commands/${COMMAND_ID}/ack" \
--request POST \
--header "Authorization: Bearer $HOST_KEY" \
--header 'X-Smoke-Signal-Host-Epoch: 3' \
--header 'Content-Type: application/json' \
--data '{ "outcome": "applied", "lease_generation": 1 }'
Run the command loop
Long-poll for one leased command or 204. Pause before polling again, and stop when the service shuts down. Decrypt and validate the payload before local work. Ack with the claim's lease_generation and outcome applied, rejected, or unknown. On 409, read the command state instead of running it again.
Remote command kinds
Field
Type
Presence
Description
start
{workspace_id, prompt, task_form}
Has task
Begin a session after validating the revision-bound task form.
message
{task_id, prompt}
Has task
Send a follow-up prompt to the task session.
interrupt
{task_id}
Has task
Stop the task session's running turn.
adopt
{workspace_id, session_id, prompt}
Has task
Attach an idle local session and continue it with a prompt.
list_sessions
{workspace_id}
No task
Publish the encrypted session catalog for a workspace.
Report task state
Publish status and per-Device encrypted summary/detail projections with PUT /api/v1/remote-host/tasks/{task_id} whenever the task changes. Keep the decrypted transcript to 32 entries and 96 KiB. For list_sessions, publish up to 50 encrypted entries with PUT /api/v1/remote-host/session-catalog/{workspace_id}.