Skip to content
Smoke Signal

Developer docs

Send notifications with a webhook or API key.

Create a Channel, then send the same JSON payload to its webhook URL or use its API key with the Notifications endpoint.

Before you start

Create or reveal a channel to get both credentials. Keep them in server-side secrets, never expose them publicly.

Open channels

Secret URL

Webhook

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.

POSThttps://api.smokesignal.sh/webhooks/v1/notifications/{channel_secret}
cURL
WEBHOOK_URL='https://api.smokesignal.sh/webhooks/v1/notifications/smk_your_channel_secret'

curl "$WEBHOOK_URL" \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: 78cc7a79-2ab8-4a24-a85c-1973c9c846fa' \
  --data '{
    "title": "Deploy complete",
    "body": "Production is running version 1.8.0.",
    "url": "https://example.com/deployments/418",
    "ttl_seconds": 3600
  }'

Bearer key

API

The secret is in the Authorization header. Use the API key revealed for the channel you want to reach.

POSThttps://api.smokesignal.sh/api/v1/notifications
cURL
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
titlestringRequiredNotification title. From 1 through 80 Unicode characters.
bodystringOptionalSupporting text. Up to 240 Unicode characters.
urlstringOptionalAbsolute HTTPS URL opened on tap. Maximum 2,048 bytes; no user information.
ttl_secondsintegerOptionalDelivery lifetime from 60 through 86,400 seconds. Defaults to 3,600.
to_inboxbooleanOptionalTrue 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_idstringAlwaysOpaque identifier for the accepted notification.
logical_audienceintegerAlwaysSubscribers 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_creditsintegerAlwaysCredits charged when the notification was accepted.
free_credits_consumedintegerAlwaysHow much of the charge was covered by the weekly free allowance.
paid_credits_chargedintegerAlwaysHow much of the charge was taken from the paid balance.
resulting_balanceintegerAlwaysCredit balance after the charge.
droppedbooleanAlwaysTrue 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_atstringAlwaysRFC 3339 timestamp when Smoke Signal accepted the send.
expires_atstringAlwaysRFC 3339 timestamp when delivery is no longer allowed.
to_inboxbooleanAlwaysTrue when this send is saved to each subscriber's inbox.
inbox_untilstringWhen to_inboxRFC 3339 timestamp when the entry leaves every subscriber's inbox. Present exactly when to_inbox is true.

Response example

{
  "message_id": "STtWzi0Hjx2-SdXgVJQvNg",
  "logical_audience": 12,
  "charged_credits": 12,
  "free_credits_consumed": 0,
  "paid_credits_charged": 12,
  "resulting_balance": 488,
  "dropped": false,
  "accepted_at": "2026-07-31T14:22:10Z",
  "expires_at": "2026-07-31T15:22:10Z",
  "to_inbox": false
}

Questions with answers

Decisions

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.

POSThttps://api.smokesignal.sh/api/v1/decisions
cURL
CHANNEL_API_KEY='smk_your_channel_secret'

curl 'https://api.smokesignal.sh/api/v1/decisions' \
  --request POST \
  --header "Authorization: Bearer $CHANNEL_API_KEY" \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $(openssl rand -hex 16)" \
  --data '{
    "question": "Promote build 1.8.0 to production?",
    "detail": "acme/web at 4f2c9ab. Staging is green.",
    "ttl_seconds": 900,
    "actions": [
      { "id": "promote", "label": "Promote" },
      { "id": "hold", "label": "Hold" }
    ]
  }'

Decision schema

Send only these fields. Unknown fields are rejected, not ignored.

Decision request fields
Field Type Presence Description
questionstringRequiredThe decision to make. From 1 through 200 Unicode characters.
detailstringOptionalSupporting context shown under the question. Up to 500 Unicode characters.
actionsarrayRequiredTwo 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_secondsintegerOptionalAnswer 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_idstringOn 201Identifier used to poll for the reply and to cancel the Decision.
expires_atstringOn 201RFC 3339 timestamp after which no answer is accepted.
statusstringOn 200The 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.
reasonstringOn 200Why nothing was sent. Currently always "no_subscribers".
chargedintegerAlwaysCredits 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.

POSThttps://api.smokesignal.sh/api/v1/decisions/{decision_id}/reply/claim
cURL
# 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_idstringOn 200The id of the choice the subscriber picked. The answer is yours once claimed.
replied_atstringOn 200RFC 3339 timestamp of the answer.
statusstringOn 202The literal "pending". The wait elapsed with no answer yet. Pause before polling again.
expires_atstringOn 202RFC 3339 timestamp after which polling returns 410 instead.
DELETEhttps://api.smokesignal.sh/api/v1/decisions/{decision_id}

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.

POSThttps://api.smokesignal.sh/api/v1/remote-pairing/exchange
cURL
# 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_locatorstringRequiredUnpadded base64url of the first 32 derived bytes. The pairing expires after 10 minutes.
host_public_keystringRequiredUnpadded base64url P-256 public key. Generate it locally and never send the private key.
host_key_proofstringRequiredBase64url HMAC-SHA256 proof of host_public_key using the final 16 derived bytes.
Pairing exchange response fields
Field Type Presence Description
host_idstringAlwaysIdentifier of the paired host.
host_keystringAlwayssmb_ bearer key returned once for bridge routes. Store it like a secret.
originstringAlwaysAPI origin the bridge should call from now on.
namestringAlwaysOperator-chosen display name for this machine.
agent_kindstringAlwaysIntegration this pairing drives: claude-code, codex, or custom.
deviceobjectAlwaysThe 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.

GEThttps://api.smokesignal.sh/api/v1/remote-host/commands/claim?wait=25
cURL
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 taskBegin a session after validating the revision-bound task form.
message{task_id, prompt}Has taskSend a follow-up prompt to the task session.
interrupt{task_id}Has taskStop the task session's running turn.
adopt{workspace_id, session_id, prompt}Has taskAttach an idle local session and continue it with a prompt.
list_sessions{workspace_id}No taskPublish 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}.

DELETEhttps://api.smokesignal.sh/api/v1/remote-host

Unpair from the bridge side: deletes the pairing, its tasks, and its history, and revokes the host key. Returns 204.