Skip to content
EmitKitDocs
Esc
↑↓navigate↵open⌘Jpreview
On this page

Ask for a decision

Send an event with buttons and fields, and get the answer back when someone taps it on their phone.

An event can ask something. Add buttons (actions with an id) and, if you need more than a yes or no, fields. The event then waits for an answer: members who turned on notifications for its Channel get a push that opens the answer page, the first person to answer decides, and you read the answer back.

It’s made for agents and automations that need a human in the loop: approve a refund, ship a deploy, pick a headline, reply to a customer.

Ask

curl https://api.emitkit.com/v1/events \
  -H "Authorization: Bearer $EMITKIT_API_KEY" \
  -H "Idempotency-Key: refund-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "channelName": "refunds",
    "title": "Refund Jane Cooper for order #1042?",
    "description": "Arrived damaged: 2 of 3 items broken.",
    "fields": [
      {
        "id": "resolution",
        "type": "choice",
        "label": "Resolution",
        "options": [
          { "id": "full", "label": "Full refund ($240)" },
          { "id": "partial", "label": "Broken items only ($160)" }
        ],
        "default": "partial"
      },
      { "id": "note", "type": "text", "label": "Note to Jane", "multiline": true }
    ],
    "actions": [
      { "id": "approve", "label": "Refund", "style": "primary" },
      { "id": "deny", "label": "Don'\''t refund", "style": "destructive", "requires": ["note"] },
      { "label": "Open order", "url": "https://shop.example.com/admin/orders/1042" }
    ],
    "expiresIn": 1800
  }'

The response is the usual 201 with the event, including its fields, actions and answer (shortened here):

{
  "success": true,
  "requestId": "5f0c6b1e-…",
  "data": {
    "id": "event_…",
    "title": "Refund Jane Cooper for order #1042?",
    "fields": [ … ],
    "actions": [ … ],
    "answer": {
      "status": "pending",
      "expiresAt": "2026-09-26T10:30:00.000Z",
      "url": "https://app.emitkit.com/a/event_…",
      "action": null,
      "values": null,
      "by": null,
      "at": null
    }
  }
}

data.answer.url is the answer page. Pushes for this event open it, the dashboard feed marks the event “Waiting for an answer” with an Answer button, and you can share the link with anyone in your Organization. For an event that doesn’t ask anything, answer is null.

Send an Idempotency-Key: if your request is retried, you get the same event back instead of asking twice. A replay returns the event as it was created (pending); read the current answer with GET.

Actions (up to 4) are either:

  • a button, { "id", "label", "style"?, "requires"? }: pressing it is the answer. id is what you get back (submit is taken, see below). style is primary, destructive, or left out. requires lists text or multiple-choice fields that must be filled in first, so “Don’t refund” can insist on a reason. (Other fields always have a value, so they can’t be required.)
  • a link, { "label", "url" }: opens a page (https). It never answers. Links show on the answer page and in the event’s details in the dashboard feed, on any event.

Fields (up to 5) describe data, not controls. The answer page picks the control:

type Settings Answer value
choice options (2–10 { id, label }), multiple, default an option id; an array of ids with multiple
text multiline, maxLength (≤ 2,000), placeholder, default a string (left out when empty)
number min, max, step, unit (like $ or %), default a number
boolean default true or false

An event waits for an answer when it has a button or a field. Fields without buttons get a Send button, whose answer is "submit". An event with only links is an ordinary event with links.

Fields, options and actions only take the settings above: anything else (such as "required": true) is rejected with a message saying what to use instead.

A single choice always has a value: its default, or the first option. An event with one single choice and no buttons is answered with one tap.

Get the answer

Two ways, both always available: EmitKit calls you back, or you poll.

Callbacks

Add callbackUrl (https) and EmitKit POSTs there once the question is answered, or when it expires unanswered. Add resume (up to 65,536 characters of your own state, such as a job id) and it comes back as is, so a serverless handler can carry on without a database:

{
  "channelName": "refunds",
  "title": "Refund Jane Cooper for order #1042?",
  "actions": [{ "id": "approve", "label": "Refund" }, { "id": "deny", "label": "Don't refund" }],
  "callbackUrl": "https://shop.example.com/api/emitkit",
  "resume": "refund-job-1042"
}

The callback:

POST /api/emitkit
content-type: application/json
webhook-id: msg_event_…_answered
webhook-timestamp: 1790330640
webhook-signature: v1,…

{
  "type": "event.answered",
  "eventId": "event_…",
  "answer": { "status": "answered", "action": "approve", "values": {}, "by": { … }, "at": "…", "expiresAt": "…", "url": "…" },
  "resume": "refund-job-1042"
}

type is event.answered or event.expired (then answer.action is null). Nothing is sent when you cancel.

It’s signed the Standard Webhooks way (what Svix uses), with your project’s callback signing secret: owners and admins find it in Settings → API keys. Verify with any Standard Webhooks library before you act on it:

import { Webhook } from "standardwebhooks";

// whsec_… (an empty secret throws, so a missing variable fails at startup)
const webhook = new Webhook(process.env.EMITKIT_CALLBACK_SECRET ?? "");

export const POST = async (request: Request) => {
  const body = await request.text();
  const callback = webhook.verify(body, Object.fromEntries(request.headers));
  // callback.type, callback.answer.action, callback.resume …
  return new Response(null, { status: 204 });
};

Answer with any 2xx. Anything else, or no answer within 10 seconds, is retried with backoff for about 16 hours (10 attempts), with the same webhook-id every time, so you can drop repeats. Redirects aren’t followed. GET /v1/events/{id} shows how delivery went in callback: { url, attempts, lastStatus, deliveredAt }.

Delivery is at least once: the same webhook-id can arrive twice, even after you answered 2xx. For a decision that moves money, you can also read the answer back with GET /v1/events/{id} before you act; it needs your API key, which a leaked signing secret doesn’t give anyone.

Polling

Poll the event until data.answer.status is no longer pending. Every few seconds is fine: the rate limit is 100 requests per minute per API key.

curl https://api.emitkit.com/v1/events/event_… \
  -H "Authorization: Bearer $EMITKIT_API_KEY"
{
  "success": true,
  "requestId": "…",
  "data": {
    "id": "event_…",
    "answer": {
      "status": "answered",
      "action": "approve",
      "values": { "resolution": "partial" },
      "by": { "id": "…", "name": "Chris", "email": "chris@example.com" },
      "at": "2026-09-26T10:04:12.000Z",
      "expiresAt": "2026-09-26T10:30:00.000Z",
      "url": "https://app.emitkit.com/a/event_…"
    }
  }
}

values has every field: untouched ones at their starting value (a single choice’s first option or default, a number’s default or min, false), and text only when filled in.

status Means
pending Waiting.
answered Someone pressed a button. action is its id, values the fields by id, by who.
expired expiresIn seconds passed without an answer (10 to 604800; default 86400, 24 hours).
canceled You canceled it.

The first answer wins. Anyone who opens the page later sees who answered and what they chose. The answer is also recorded in the Channel as an event (“Chris chose “Refund””), so it shows up in your feed.

Treat anything but the answer you’re waiting for as “no”: an expired question wasn’t approved.

Cancel

When you no longer need the answer (the refund was handled another way, a newer deploy replaced this one), cancel it:

curl -X POST https://api.emitkit.com/v1/events/event_…/cancel \
  -H "Authorization: Bearer $EMITKIT_API_KEY"

The answer page then says it’s no longer needed. Canceling twice is fine: you get the canceled event back. Once it was answered or expired you get 409 with code: "not_pending" and a message saying who answered it, or that it expired; an event that never asked anything gets 409 with code: "not_waiting".

Mistakes

A question that doesn’t add up is rejected with 400, code: "validation_error", and a details entry per problem with its path and how to fix it:

{
  "success": false,
  "code": "validation_error",
  "error": "Validation error",
  "details": [
    {
      "code": "invalid",
      "path": ["actions", 1, "requires", 0],
      "message": "\"reason\" is not a field; field ids are resolution, note"
    }
  ],
  "requestId": "…"
}

Besides the shapes above: callbackUrl and resume only go on an event that waits, and callbackUrl can’t point at EmitKit itself. Field and button ids are unique, submit isn’t a button id, requires names text or multiple-choice fields, a choice’s default names its options, expiresIn only goes on an event that waits, and an event that waits always notifies (notify: false is rejected).

From an agent

The MCP server has the same operations as tools: createEvent takes fields, actions and expiresIn, getEvent reads the answer, and cancelEvent cancels.

Was this page helpful?