---
title: "Send a read, working or typing signal about an email"
method: POST
path: "/emails/{id}/signal"
tags: ["Sending"]
---

# Send a read, working or typing signal about an email

`POST /emails/{id}/signal`

## Request

- Base URL: `https://api.primitive.dev/v1`
- URL: `https://api.primitive.dev/v1/emails/{id}/signal`
- Auth: HTTP bearer

Builds and sends the standard signal email about this received email: one `interaction.json` part (read/1, working/1 or typing/1 naming the email's Message-ID) plus only the fixed fallback text, threaded to it. It goes only to the email's authenticated sender, never to Reply-To or other recipients. Receivers see it as `fyi: true`, and the sender sees it as `peer_signal_on_my_last` (typing is not reported there). Refused (422) for a signal, interaction or other structured email, for an unauthenticated sender, and while the email is still being processed. Each call is a new signal with fresh ids; a read retried with the same Idempotency-Key rebuilds the identical message and dedupes.

## Path parameters

- `id` string, uuid, required

## Headers

- `Idempotency-Key` string

## Request body

- EmailSignalInput
  - `kind` 'read' | 'working' | 'typing', required: `read`: you read the email. `working`: you are working on it. `typing`: you are writing a reply.
  - `expires_in_seconds` integer: working and typing only: how long the signal holds. Defaults to 30. Not allowed for read.

## Response `200`

Success

- object
  - `success` true, required
  - `data` SendMailResult, required
    - `repeat_id` string, uuid: Present when the request carried `repeat`. Manage it under `/repeating-sends/{id}`.
    - `id` string, required: Persisted sent-email attempt ID.
    - `status` 'queued' | 'submitted_to_agent' | 'agent_failed' | 'gate_denied' | 'unknown' | 'delivered' | 'bounced' | 'deferred' | 'wait_timeout' | 'scheduled' | 'canceled', required: Lifecycle status of a sent_emails row. Possible values: - `queued`: pre-call INSERT; the outbound agent has not yet replied. - `submitted_to_agent`: agent accepted; `queue_id` is set. - `agent_failed`: agent rejected; `error_code` and `error_message` carry the reason. - `gate_denied`: a recipient-scope gate denied the send; the agent was never called. The `gates` array carries the denial detail. /send-mail returns 403 in this case so callers see the denial synchronously; /sent-emails additionally records the row for historical lookup, which is when this status appears in a listing. - `unknown`: terminal indeterminate; the on-box log poller couldn't classify the receiver's response. - `delivered` / `bounced` / `deferred` / `wait_timeout`: terminal delivery outcomes (see DeliveryStatus). - `scheduled`: created with a future `scheduled_at` and not yet executed; `scheduled_at` carries the pending execution time. Reschedulable via PATCH /sent-emails/{id} and cancelable via /sent-emails/{id}/cancel while in this status. - `canceled`: terminal; a scheduled send canceled before execution. `canceled_at` carries the cancellation time and nothing was dispatched.
    - `from` string, required: Bare from-address actually written on the wire. Echoed on every success branch so callers can confirm what went out, particularly useful for the /emails/{id}/reply path where `from` is server-derived from the inbound's recipient when the caller doesn't override. For sends where the caller passed a from-header that included a display name (e.g. `"Acme Support" <support@acme.test>`), this field is the parsed bare address (`support@acme.test`). The display name was sent on the wire intact; this field just makes the address easy to compare against allowlists.
    - `queue_id` string, nullable, required: Message identifier assigned by Primitive's OUTBOUND relay (the box that signs your mail and submits it to the receiving MTA). NOT the receiver's queue id. The receiver may also report its own queue id in `smtp_response_text` (e.g. `"250 2.0.0 Ok: queued as 99D111927CDA"` from a Postfix receiver). Those two ids refer to different mail systems and are NOT comparable. Treat `queue_id` as Primitive-internal and the receiver's id as remote-system-internal. Null on rows that never reached the relay (queued, gate_denied, agent_failed before signing).
    - `accepted` string[], required: Recipient addresses accepted by the relay.
    - `rejected` string[], required: Recipient addresses rejected by the relay.
    - `client_idempotency_key` string, required: Effective idempotency key used for this send.
    - `request_id` string, required: Server-issued request identifier for support and tracing.
    - `content_hash` string, required: Stable hash of the canonical send payload.
    - `delivery_status` 'delivered' | 'bounced' | 'deferred' | 'wait_timeout': Narrower enum covering only the four terminal delivery outcomes returned to a synchronous `wait: true` send. On the SendMailResult shape, `delivery_status` is always equal to `status` whenever both are present (i.e. on terminal-state replays and live wait=true responses). The two fields exist so callers that want to type-narrow on "this is a delivery outcome" can pattern-match against the four-value enum without handling the broader SentEmailStatus value set (which also covers `queued`, `submitted_to_agent`, `agent_failed`, `gate_denied`, `unknown`). On async-mode and pre-terminal responses, `delivery_status` is absent and only `status` is populated. Use `status` if you want a single field that's always present.
    - `smtp_response_code` integer, nullable: SMTP response code from the first downstream delivery outcome when wait is true.
    - `smtp_response_text` string: SMTP response text from the first downstream delivery outcome when wait is true.
    - `idempotent_replay` boolean, required: True when the response replays a previously-recorded send keyed by `client_idempotency_key` (same key, same canonical payload). False on a fresh send and on gate-denied responses. Lets callers branch on cache state without diffing fields.
    - `scheduled_at` string, date-time: Echoed requested execution time on a `scheduled` response. On scheduled creates, nothing is dispatched yet: `queue_id` is null and `accepted` / `rejected` are empty. Absent on immediate sends.
    - `idempotency` SendMailIdempotencyReplay: Present only when the request was answered with an existing send instead of making a new one (`idempotent_replay: true`). Nothing was sent for this request. Says how the idempotency key was derived and which send the request collapsed onto.
      - `replayed` true, required: Always `true`. The object is absent on a request that made a send of its own.
      - `key_source` 'explicit' | 'auto_content' | 'function_trigger', required: `explicit`: the request carried an `Idempotency-Key` header. `auto_content`: no header was sent, so the key was derived from the canonical request content (recipients included) and a fixed 5-minute window; an identical send inside the same window was not sent again. `function_trigger`: no header on a send made by a Function, so the key was derived from the content, the Function and the inbound email (or event) that invoked it.
      - `original_sent_email_id` string, uuid, required: The send this request collapsed onto. Same value as `id`.
      - `original_created_at` string, date-time, nullable, required: When that send was created.
      - `window_seconds` integer, nullable, required: Length of the content window (300) when an `auto_content` key matched on content. Null for `explicit` and `function_trigger` keys, which have no window, and for a keyless reply matched because its parent already has a reply (`dedup_reason: parent_already_replied`).
    - `dedup_reason` 'content_hash_match' | 'parent_already_replied' | 'null', nullable: Why the request was answered with an existing send. Set on every replay (`idempotent_replay: true`), whether it answers with `200` or with the stored error of an earlier send that failed. `content_hash_match`: the request's idempotency key matched an earlier send. That key is the `Idempotency-Key` header when one was sent, otherwise the key derived from the request content (see `idempotency.key_source`). `parent_already_replied`: a send with no `Idempotency-Key` whose `in_reply_to` names an inbound email that already has a reply from this organization that is queued, in flight, delivered or of unknown outcome. The existing reply is returned even when the content differs, and `idempotency.window_seconds` is null. This applies to `/emails/{id}/reply`, which always sets `in_reply_to`, and to `/send-mail` and batch messages that pass it. It does not apply to an `fyi` acknowledgement or to a send made with an agent connection key. Pass an `Idempotency-Key` to send a second reply. Absent or null on a request that made a send of its own.

## Other responses

- `400`: Invalid request parameters
- `401`: Invalid or missing API key
- `403`: Authenticated caller lacks permission for the operation
- `404`: Resource not found
- `422`: The email cannot be signaled (structured mail, unauthenticated sender, not yet processed, or your own email).
- `429`: Rate limit exceeded. The `ratelimit-*` headers describe the limiter that rejected this request, with its own limit and window, not the API budget that successful responses report. A limiter that reports only a wait sends `Retry-After` alone.

## Changes

> 32 revisions in range; 1 not diffed.

- **2026-11-03** `bce409c97acb`: 186 warning
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `400`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `401`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `403`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `404`
  - …182 more
  - …this revision’s changelog is incomplete
- **2026-10-21** `0360ea8d4f3d`: 78 warning
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `400`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `401`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `403`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `404`
  - …74 more
- …earlier changes not shown

[Operation history](https://skmtc.dev/primitive/apis/primitive-api/changes/emails/:id/signal/post.md)

---

[API](https://skmtc.dev/primitive/apis/primitive-api.md) - [All operations](https://skmtc.dev/primitive/apis/primitive-api/llms.txt) - [OpenAPI document](https://skmtc.dev/primitive/apis/primitive-api/revisions/bce409c97acb?raw)
