Primitive Search API
Semantic and hybrid search across received and sent mail
Semantic and hybrid search across received and sent mail
openapi: 3.1.0
info:
title: Primitive Account Search API
version: 1.0.0
description: "Primitive is email infrastructure for AI agents. The Primitive API lets you manage domains, emails, webhook endpoints,\nfilters, and account settings programmatically.\n\n## Authentication\n\nMost endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer prim_<your_api_key>\nAuthorization: Bearer prim_oat_<oauth_access_token>\n```\n\nAPI keys and OAuth access tokens are org-scoped. Create and manage them in your dashboard\nunder Settings > API Keys. CLI login plus CLI/agent signup endpoints\nexplicitly declare `security: []`; they do not require an API key because\nthey are used to create OAuth CLI sessions.\n\n## Rate Limiting\n\nThe API enforces a sliding window rate limit of **120 requests per\n60 seconds** per organization. When exceeded, the API returns `429`\nwith a `Retry-After` header indicating how many seconds to wait.\n\n## Pagination\n\nList endpoints use cursor-based pagination. Responses include a\n`meta` object with `total`, `limit`, and `cursor` fields. Pass the\n`cursor` value as a query parameter to fetch the next page. When\n`cursor` is `null`, there are no more results.\n\n## Response Format\n\nAll responses use a consistent envelope:\n\n```json\n{\n \"success\": true,\n \"data\": { ... },\n \"meta\": { \"total\": 42, \"limit\": 50, \"cursor\": \"...\" }\n}\n```\n\nErrors follow the same pattern:\n\n```json\n{\n \"success\": false,\n \"error\": { \"code\": \"not_found\", \"message\": \"Email not found\" }\n}\n```\n\n## Webhook signing\n\nOutbound webhook deliveries (configured via the `endpoints` API)\nare signed so receivers can verify they came from Primitive and\nhave not been tampered with in transit. The signing scheme is\ndeliberately simple so it can be reimplemented in any language\nin a few lines. The Node SDK's `verifyWebhookSignature` helper\nis the reference implementation; the wire details below let you\nwrite a verifier in Python, Go, Ruby, etc. without reading our\nsource.\n\n**Header**: `Primitive-Signature: t=<unix-seconds>,v1=<hex>`\n\nA legacy `MyMX-Signature` header is also sent on every delivery\nwith the same value, retained for back-compatibility with\nintegrations written before the rename. New code should read\n`Primitive-Signature`.\n\n**Signed string**: `${timestamp}.${rawBody}` where `timestamp`\nis the Unix-seconds integer from the `t=` parameter and\n`rawBody` is the exact bytes of the HTTP request body BEFORE\nany JSON decoding. Verify against the raw body, not a\nre-serialized parse, or you will silently mismatch on\ninsignificant whitespace.\n\n**Signature**: HMAC-SHA256 of the signed string, hex-encoded\n(lowercase). Use the account's webhook secret as the HMAC key,\nas a UTF-8 byte sequence.\n\n**Secret**: returned by `GET /account/webhook-secret`. The\nstring looks base64-shaped (e.g. `XNHBBW8VqoBjRfNs1tkZj11jTk...`)\nbut is NOT base64; use it AS-IS as a UTF-8 string for the HMAC\nkey. Base64-decoding before HMAC will silently produce\nmismatched signatures.\n\n**Tolerance**: by convention, reject deliveries whose `t=`\ntimestamp is more than 5 minutes off your wall-clock to defend\nagainst replay attacks. The Node SDK's helper enforces this by\ndefault.\n\n**Verification recipe** (any language):\n\n```\n1. Read the raw HTTP body (do not parse).\n2. Read `Primitive-Signature: t=<ts>,v1=<sig>`.\n3. Reject if abs(now - ts) > 300 seconds.\n4. expected = HMAC_SHA256_hex(secret_utf8, f\"{ts}.{rawBody}\")\n5. Constant-time compare expected to sig. Reject if not equal.\n```\n\nFor Node, use `verifyWebhookSignature` from\n`@primitivedotdev/sdk/webhook` (or the higher-level\n`handleWebhook` helper if you want a one-liner). For other\nlanguages, the recipe above is everything you need.\n\nTest deliveries: `POST /endpoints/{id}/test` triggers a fake\ndelivery to your endpoint URL, signed with your real account\nsecret, so you can confirm verification end-to-end without\nneeding real inbound mail. The test response carries the exact\n`signature` header value sent on the wire so you can compare\nstrings directly.\n\n\n## Errors\n\nEvery error response is the same JSON envelope (`{ \"success\": false, \"error\": { \"code\", \"message\" } }`), served as `application/json` with HTTP status codes, following the RFC 7807 problem-details shape. The `error.code` is a stable machine-readable string and `error.message` is human-readable.\n\n## Authorization and roles\n\nAccess is governed by organization role-based access control. Every organization member holds one of three roles — `owner`, `admin`, or `member` — and a credential inherits a role. **API keys** always act at `member` level, regardless of the role of the user who created them, so an API key can never perform owner- or admin-only actions. **OAuth access tokens** act with the authorizing user's current organization role, resolved on each request. Every operation in this spec is part of the member-level surface, so any valid credential can call it. Organization administration that is not part of this API — billing and organization settings — requires an `owner` or `admin` and is performed in the dashboard. Fine-grained per-key scopes (e.g. a send-only or read-only key) are on the roadmap; today the role model is the unit of access control.\n\n## Versioning\n\nThe current stable API is **v1**. All endpoints are served under `/v1/` and are covered by a backward-compatibility guarantee: existing fields and status codes will not change without a deprecation notice.\n\nBreaking changes are announced at least 6 months in advance via changelog and email. Deprecated operations and fields are marked `x-deprecated: true` in the spec and carry a plain-English description of the replacement. The `v1` path prefix is guaranteed stable indefinitely; backward-compatible additions (new optional fields, new endpoints) may be made at any time without a version bump."
contact:
name: Primitive
url: https://primitive.dev
license:
name: Proprietary
url: https://primitive.dev/terms
x-stability-level: stable
x-deprecation-policy: 'Breaking changes are announced at least 6 months in advance. Deprecated fields carry x-deprecated: true. The current stable version is v1.'
servers:
- url: https://api.primitive.dev/v1
description: Canonical API host (PRIMITIVE_API_BASE_URL). Carries every public API operation.
tags:
- name: Search
description: Semantic and hybrid search across received and sent mail
paths:
/semantic-search:
post:
operationId: semanticSearch
summary: Semantic search across received and sent mail
description: 'Ranked search across both received and sent mail. The `mode`
field selects the ranking strategy:
- `keyword`: lexical full-text matching only (no embeddings).
- `semantic`: meaning-based matching using vector embeddings.
- `hybrid` (default): blends the semantic and keyword signals.
Results are ordered by a relevance `score`. Every row reports the
fields it matched (`matched_fields`), a match-centered excerpt per
field (`snippets`), and a `score_breakdown` whose components account
for the `score`. Page through results by passing the prior
response''s `meta.cursor` back as `cursor`.
Requires the Pro plan and the `semantic_search_enabled`
entitlement; callers without them receive `403`.
Host routing: this operation is served only by the search host
(`https://api.primitive.dev/v1`). The typed SDKs route it there
automatically.
'
servers:
- url: https://api.primitive.dev/v1
description: Search host
tags:
- Search
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
query:
type: string
minLength: 1
maxLength: 2048
description: 'Free-text query. Required for `semantic` and `hybrid` modes;
optional for `keyword` mode.
'
mode:
type: string
enum:
- hybrid
- semantic
- keyword
default: hybrid
description: 'Ranking strategy. `keyword` is lexical only, `semantic` is
embedding-based, `hybrid` blends both.
'
corpus:
type: array
items:
type: string
enum:
- inbound
- outbound
minItems: 1
maxItems: 2
description: 'Which mail to search. Defaults to both received (`inbound`)
and sent (`outbound`).
'
search_in:
type: array
items:
type: string
enum:
- subject
- headers
- addresses
- body
description: A searchable email field.
description: Restrict matching to these fields. Defaults to all.
exclude:
type: array
items:
type: string
enum:
- subject
- headers
- addresses
- body
description: A searchable email field.
description: Exclude these fields from matching.
date_from:
type: string
format: date-time
description: Only include mail at or after this timestamp.
date_to:
type: string
format: date-time
description: Only include mail at or before this timestamp.
include:
type: array
items:
type: string
enum:
- coverage
description: 'Opt-in extras. `coverage` adds an index-coverage snapshot to
`meta`. Matched fields, snippets, and the score breakdown are
always returned regardless of this field.
'
limit:
type: integer
minimum: 1
maximum: 100
default: 10
description: Maximum number of results to return.
cursor:
type: string
description: Opaque pagination cursor from a prior response's `meta.cursor`.
responses:
'200':
description: Ranked search results
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: array
items:
type: object
properties:
source_type:
type: string
enum:
- inbound_email
- sent_email
description: Whether this row is a received or sent message.
id:
type: string
description: Message id. Combine with `api_url` to fetch the full record.
subject:
type:
- string
- 'null'
from:
type:
- string
- 'null'
to:
type:
- string
- 'null'
timestamp:
type: string
description: Message timestamp (received_at for inbound, created_at for sent).
status:
type: string
description: Lifecycle status of the message.
score:
type: number
description: Overall relevance score; the `score_breakdown` components account for it.
semantic_score:
type:
- number
- 'null'
description: Raw semantic similarity signal, or null when not applicable.
keyword_score:
type:
- number
- 'null'
description: Raw keyword (lexical) signal, or null when not applicable.
matched_fields:
type: array
items:
type: string
enum:
- subject
- headers
- addresses
- body
description: A searchable email field.
description: Fields where the query matched.
snippets:
type: array
items:
type: object
properties:
field:
type: string
description: The field this excerpt came from.
text:
type: string
description: Plain-text excerpt centered on the match (no markup).
required:
- field
- text
description: Match-centered excerpts, one per matched field.
score_breakdown:
type: object
description: 'Additive contributions to `score`. `semantic` and `keyword` are the
raw signals times the mode''s weight (null when not applicable);
these plus `field_boost` and `recency` sum to `score` before each
value is independently rounded to 5 decimal places.
'
properties:
semantic:
type:
- number
- 'null'
keyword:
type:
- number
- 'null'
field_boost:
type: number
recency:
type: number
required:
- semantic
- keyword
- field_boost
- recency
api_url:
type:
- string
- 'null'
description: Relative API path to fetch the full message.
required:
- source_type
- id
- subject
- from
- to
- timestamp
- status
- score
- semantic_score
- keyword_score
- matched_fields
- snippets
- score_breakdown
- api_url
meta:
type: object
properties:
limit:
type: integer
description: Page size used for this request.
cursor:
type:
- string
- 'null'
description: Cursor for the next page, or null if there are no more results.
mode:
type: string
enum:
- hybrid
- semantic
- keyword
description: Ranking mode used for this response.
coverage:
oneOf:
- type: object
description: Index-coverage snapshot for the org, returned only when the `coverage` include option is requested.
properties:
embedded_chunks:
type: integer
pending_chunks:
type: integer
skipped_plan_chunks:
type: integer
skipped_quota_chunks:
type: integer
unsupported_attachment_chunks:
type: integer
failed_chunks:
type: integer
required:
- embedded_chunks
- pending_chunks
- skipped_plan_chunks
- skipped_quota_chunks
- unsupported_attachment_chunks
- failed_chunks
- type: 'null'
description: 'Index-coverage snapshot, present only when requested via
`include: [coverage]`; otherwise null.
'
required:
- limit
- cursor
- mode
- coverage
required:
- data
- meta
headers:
ratelimit-limit:
description: Maximum number of requests allowed in the current window.
schema:
type: integer
minimum: 1
example: 120
ratelimit-remaining:
description: Remaining requests in the current window.
schema:
type: integer
minimum: 0
example: 118
ratelimit-reset:
description: Unix timestamp (seconds) when the current window resets.
schema:
type: integer
example: 1700000060
ratelimit-policy:
description: Rate-limit policy in `limit;w=seconds` format, e.g. `120;w=60`.
schema:
type: string
example: 120;w=60
'400':
$ref: '#/components/responses/ValidationError'
description: Invalid request parameters
'401':
$ref: '#/components/responses/Unauthorized'
description: Invalid or missing API key
'403':
$ref: '#/components/responses/Forbidden'
description: Authenticated caller lacks permission for the operation
'429':
$ref: '#/components/responses/RateLimited'
description: Rate limit exceeded
'500':
$ref: '#/components/responses/InternalError'
description: Primitive encountered an internal error
'503':
$ref: '#/components/responses/ServiceUnavailable'
description: Primitive is temporarily unable to process the request
security:
- BearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
description: Optional client-supplied idempotency key. Retrying a request with the same key returns the original result instead of performing the action a second time; if omitted the server derives one from the canonical payload hash. Safe to retry network failures without duplicating side effects.
schema:
type: string
minLength: 1
maxLength: 255
components:
responses:
InternalError:
description: Primitive encountered an internal error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: internal_error
message: Internal server error
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: rate_limit_exceeded
message: Rate limit exceeded
ServiceUnavailable:
description: Primitive is temporarily unable to process the request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: outbound_capacity_exhausted
message: Outbound capacity is temporarily exhausted
Unauthorized:
description: Invalid or missing API key
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: unauthorized
message: Invalid or missing API key
Forbidden:
description: Authenticated caller lacks permission for the operation
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: forbidden
message: Insufficient permissions
ValidationError:
description: Invalid request parameters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
success: false
error:
code: validation_error
message: Invalid domain format
schemas:
GateDenial:
type: object
properties:
name:
type: string
enum:
- send_to_confirmed_domains
- send_to_known_addresses
description: Public recipient-scope gate name that denied the send.
reason:
type: string
enum:
- domain_not_confirmed
- recipient_unauthenticated
- recipient_not_known
description: Stable machine-readable denial reason.
message:
type: string
description: Human-readable explanation of the gate denial.
subject:
type: string
description: Domain or address the gate evaluated.
fix:
$ref: '#/components/schemas/GateFix'
docs_url:
type: string
description: Public docs URL with more context.
required:
- name
- reason
- message
- subject
ErrorResponse:
type: object
properties:
success:
type: boolean
const: false
error:
type: object
properties:
code:
type: string
enum:
- unauthorized
- forbidden
- not_found
- validation_error
- rate_limit_exceeded
- internal_error
- conflict
- mx_conflict
- outbound_disabled
- cannot_send_from_domain
- recipient_not_allowed
- outbound_key_missing
- outbound_unreachable
- outbound_key_invalid
- outbound_capacity_exhausted
- outbound_response_malformed
- outbound_relay_failed
- discard_not_enabled
- inbound_not_repliable
- search_timeout
- authorization_pending
- slow_down
- access_denied
- expired_token
- invalid_device_code
- invalid_signup_code
- invalid_signup_token
- invalid_verification_code
- email_delivery_failed
- clerk_signup_failed
- no_orgs_for_user
- org_not_accessible
- feature_disabled
- memory_conflict
- developer_usage_credit_exhausted
- no_payout_address
- ownership_proof_failed
- payment_verification_failed
- payment_declined
- challenge_expired
- settlement_failed
- template_not_installable
- scaffold_only
- invalid_variables
- unknown_secrets
- missing_secrets
- no_inbound_domain
- domain_cannot_send
- address_taken
- route_cap_reached
- name_exhausted
message:
type: string
details:
type: object
description: 'Optional structured data that callers can inspect to recover
from the error. The fields present depend on `code`. Additional
keys may be added over time without a major-version bump.
'
additionalProperties: true
properties:
mx_conflict:
type: object
description: Present when `code == mx_conflict`.
required:
- provider_name
- suggested_subdomain
properties:
provider_name:
type: string
description: Human-readable name of the detected mailbox provider (e.g. "Google Workspace").
suggested_subdomain:
type: string
description: Subdomain to try instead (e.g. "mail" for `mail.example.com`).
required_entitlements:
type: array
items:
type: string
description: Entitlements that would allow a denied send when no recipient-scope gate was granted.
sent_email_id:
type: string
description: ID of the persisted sent-email attempt associated with the error.
content_hash:
type: string
description: Content hash of the original request on idempotency cache-hit errors.
client_idempotency_key:
type: string
description: Effective idempotency key associated with the original request.
gates:
type: array
items:
$ref: '#/components/schemas/GateDenial'
description: Structured per-gate denial detail for recipient-scope send-mail failures.
request_id:
type: string
description: Server-issued request identifier for support and tracing.
required:
- code
- message
required:
- success
- error
GateFix:
type: object
properties:
action:
type: string
enum:
- confirm_domain
- sender_must_fix_authentication
- wait_for_inbound
description: Suggested next action for the caller.
subject:
type: string
description: Entity the action applies to.
required:
- action
- subject
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: 'API key with `prim_` prefix or OAuth access token with `prim_oat_` prefix: `Authorization: Bearer <token>`. Access is governed by the caller''s organization role (`owner`, `admin`, or `member`): API keys always act at `member` level regardless of who created them, and OAuth access tokens act with the authorizing user''s current organization role, resolved per request. Every operation in this spec is available to organization members; billing and organization administration are owner/admin actions performed in the dashboard and are not part of this API.'
DownloadToken:
type: apiKey
in: query
name: token
description: Signed download token provided in webhook payloads