Start a goal from a webhook
A platform webhook is an HTTP endpoint on the Sails server. An external service (CI, GitHub, Stripe, Resend, monitoring, n8n) POSTs JSON. Sails starts a background goal as you (the repository owner), not as an anonymous visitor.
Owner-only. Agents and write members cannot webhook add.
Platform webhook or site handler?
Pick the right one before you run anything. You can register both for the same service.
Platform webhook (webhook add) | Site handler (pages/api/webhooks/*.api.sh) | |
|---|---|---|
| URL | https://your-server/sails/hooks/{uuid} | https://your-site/api/webhooks/... |
| Runs | A background goal (an agent run) | Short bash on the publish server |
| Identity | You, the owner | Signed-in visitor or public handler |
| Good for | Triage, summaries, multi-step fixes, drafting replies | Fast row updates, idempotent writes |
Example of both at once: Stripe POSTs to a site handler that marks the invoice row paid, and to a platform webhook whose goal drafts the customer email. For site handlers see Put a form on a site.
Done looks like
webhook addprints a URL and (for bearer) awhk_...token.- A POST with the right auth returns
{ "status": "accepted", "goal_id": "..." }. goal wait GOAL_IDfinishes and the goal did the job in the prompt.webhook show NAMEstill prints the same URL after you change the prompt or secret.
Worked example: CI failure, investigate
Job: your CI can POST JSON when a build fails. You want a goal in sites/app that reads the payload and suggests a fix.
webhook add ci-failure \
--path sites/app \
--prompt 'Investigate this CI failure and suggest a fix: {{payload}}'
--path scopes the goal the same way goal --path does. The live usage line lists it, so pass it. --verify defaults to bearer.
The output includes:
URL: https://your-server/sails/hooks/{uuid}
Bearer token (save now, not shown again): whk_...
Save the whk_... token now. Only a hash is stored, so nobody can show it to you later.
Fire a test event
Write a small payload. type and id feed the {{event.type}} and {{event.id}} placeholders.
cat > event.json <<'EOF'
{
"type": "build.failed",
"id": "run_4821",
"branch": "main",
"step": "test",
"log_tail": "expected 200, got 500 at tests/api.test.ts:41"
}
EOF
curl -X POST "https://your-server/sails/hooks/{uuid}" \
-H "Authorization: Bearer whk_..." \
-H "Content-Type: application/json" \
-d @event.json
Expected response:
{ "status": "accepted", "goal_id": "..." }
Then wait on it and read what it did:
goal wait GOAL_ID
goal log GOAL_ID
runs list --kind goal
Point your CI at the same URL with the same header once the test run looks right.
Prompt placeholders
| Placeholder | Replaced with |
|---|---|
{{payload}} | Full JSON body (pretty-printed) |
{{event.type}} | type field |
{{event.id}} | id field |
Change the prompt, keep the URL
Do not webhook rm then webhook add to fix a prompt or secret. That mints a new URL and breaks the provider until you update it there.
webhook update ci-failure --prompt 'CI failed. Summarize and open a fix plan: {{payload}}'
webhook list
webhook show ci-failure
Stripe instead of bearer
Stripe signs with Stripe-Signature, not a bearer token. Store the whsec_... signing secret first. --origin is required on every new secret.
The safest intake is the browser, so the secret never lands in chat:
secret open --origin https://api.stripe.com stripe-webhook-secret
# prints a one-time intake URL and an INTAKE_ID; a repo owner pastes whsec_... there
secret poll INTAKE_ID
# prints: ssec_...
Skip secret open --wait inside an agent shell. It blocks and buffers output until the value arrives.
Or from a value you already hold:
secret set --origin https://api.stripe.com "whsec_..."
# prints: ssec_...
Then create the webhook:
webhook add stripe-failures \
--path sites/app \
--verify stripe \
--secret ssec_... \
--events invoice.payment_failed,payment_intent.payment_failed \
--prompt 'Stripe event {{event.type}} ({{event.id}}). Summarize what failed and suggest next steps.
{{payload}}'
webhook show stripe-failures
Paste the URL from webhook show into the Stripe Dashboard webhooks page. Content type: application/json.
--events filters on the payload type. Other events still return HTTP 200 with { "status": "ignored" } so the provider does not retry forever.
More on origins and intake: Store a secret.
Svix senders (Resend, Clerk)
Svix-backed senders sign with svix-id, svix-timestamp, and svix-signature. Same shape as Stripe, with --verify svix.
Resend, secret copied from the dashboard
secret set --origin https://api.resend.com "whsec_..."
# prints: ssec_...
webhook add resend-email \
--path sites/app \
--verify svix \
--secret ssec_... \
--events email.bounced,email.complained \
--prompt 'Email event {{event.type}}: {{payload}}'
webhook show resend-email
Resend, endpoint created through MCP or the API
Resend's create-webhook returns a new signing_secret for that endpoint. It is not the secret you stored earlier unless you store this response value. Do it in this order so the URL never changes:
# 1. Create the Sails webhook first to get a stable URL
webhook add resend-received \
--path sites/app \
--verify svix \
--secret ssec_placeholder... \
--events email.received \
--prompt 'Summarize this inbound email and suggest a reply: {{payload}}'
webhook show resend-received
# URL: https://your-server/sails/hooks/{uuid}
# 2. Create the Resend endpoint pointing at that URL
mcp call resend create-webhook --json '{
"endpoint": "https://your-server/sails/hooks/{uuid}",
"events": ["email.received"]
}'
# the response includes signing_secret; store it right away
secret set --origin https://api.resend.com "whsec_FROM_CREATE_WEBHOOK_RESPONSE"
# prints: ssec_new...
# 3. Bind the new secret without changing the URL
webhook update resend-received --secret ssec_new...
webhook check-secret resend-received
webhook check-secret prints the signing-key fingerprint. Compare it with the provider's whsec to confirm they match.
Platform agent mail is separate
Inbound mail for agent and folder inboxes ({org}-{username}@inbox.sails.app and friends) is platform-managed. Do not webhook add for that. See Mail from an agent.
You can webhook add for your own Resend account (bounces, your own receiving domain) with --verify svix, as above.
Flags
webhook add NAME --prompt TEXT --path PATH [--verify bearer|stripe|svix] [--secret ssec_...] [--events TYPE,...]
webhook update NAME [--prompt TEXT] [--events TYPE,...] [--secret ssec_...]
webhook set-secret NAME --secret ssec_...
webhook check-secret NAME
webhook list
webhook show NAME
webhook rm NAME
webhook help
| Command | What it does |
|---|---|
webhook add | Create. Bearer token shown once when --verify bearer |
webhook update | Change prompt, events, or secret without changing the URL |
webhook set-secret | Alias for webhook update --secret |
webhook check-secret | Signing-key fingerprint, to compare with the provider whsec |
webhook list | Every webhook you own |
webhook show | URL, verify mode, prompt (never the bearer token) |
webhook rm | Delete. The URL dies. Last resort |
--verify accepts bearer, stripe, or svix. Older scripts may use --provider generic|stripe|resend; those still work as aliases.
Security
- Bearer tokens are shown once on create. Only a hash is stored.
- Keep
whsec_...values out of git and chat. Prefersecret openfor browser intake. - The URL holds an unguessable UUID, but treat the bearer token and signing secret as credentials anyway.
Failure modes
| What you see | What it means | What to do |
|---|---|---|
repository owner access required | Owner-only | Stop retrying. An owner runs webhook add, or approves elevation in a Slack goal |
secret set fails asking for --origin | Every new secret needs an origin | secret set --origin https://api.stripe.com "whsec_..." (or https://api.resend.com) |
| Provider retries or gets 401 | Bad bearer or signing secret | Bearer: use the whk_... from create. Stripe or Svix: store the current whsec, then webhook update NAME --secret ssec_... |
Resend 401 right after create-webhook | Endpoint has a new signing_secret | Store that value, then webhook update NAME --secret ssec_new... and webhook check-secret NAME |
| You lost the bearer token | Shown once, only a hash is stored | Create a new webhook and update the caller to the new URL and token |
| Provider still hits an old URL | You ran webhook rm then add | Fix the URL at the provider now. Use webhook update next time |
{ "status": "ignored" } | --events filtered this type | Expected. Add the type to --events if you want a goal |
| Fast 200 but no agent work | You POSTed a published *.api.sh | The platform URL is /sails/hooks/{uuid}, not your site path |
| Agent inbox never fires | You pointed Resend at a platform webhook for agent mail | Agent mail is not webhook add. See Mail from an agent |
See Give an agent a job for goals that are not HTTP-triggered.