# Ingest contact activities (single or batch)

`POST /v1/activities`

- Authentication: required (Bearer token)
- Required scope: `api.activities.write`

Records free-form behavioral signals about contacts. The body is one
activity object or an array of up to 1000. Always returns
`202 Accepted` with a per-item result — a batch is best-effort, never
all-or-nothing. Accepted items are written asynchronously; a brief
delay before they appear on the read endpoints is expected.

Identity: supply `email` or `external_id` (or both). An unknown email
creates a stub contact (no consent state — a stub can be segmented
later but is not mailable). An unknown `external_id` without an email
is rejected per-item; when both are supplied, the `external_id` is
attached to the contact resolved (or created) by email.

Idempotency: an optional per-item `idempotency_key` dedups literal
retries for 24 hours (org-scoped) — a **best-effort retry-collapse
window**, not an exactly-once or once-per-lifetime guarantee (it can
both fail open on a Redis outage and simply expire). A replayed key
returns `status: accepted` with `duplicate: true`, usually with the
original `activity_id` so you can reconcile. The request-level
`Idempotency-Key` header is not used on this endpoint.

Dedup mode: set `dedup_mode: "once"` on an item (default is
`"retry"`, the 24h window above) to durably claim it at most once
per `(contact, name)`, **forever** — for once-per-lifetime milestones
(e.g. `signed_up`, `first_send`) where a periodic re-emit or a
dedup-window expiry must never produce a second activity.
`idempotency_key` is ignored when `dedup_mode` is `"once"`. Unlike
the default mode, `dedup_mode: "once"` fails **closed**: if the
durable claim store is unavailable the item is rejected with
`reason: dedup_unavailable` rather than risking an unclaimed
duplicate — retry it.

For once-only *enrollment* into a Drip Workflow (as opposed to
once-only activity ingestion), the correctness boundary is the
workflow's `reentry` policy, not activity dedup — see the Drip
Workflows guide.

Limits: max 1000 items per request, 1 MiB body, 32 KiB serialized
`properties` per item; a per-org item-volume cap rejects items with
per-item `reason: rate_limited` when exceeded.

## Request body

Content type: `application/json`

```json
{
  "name": "string",
  "email": "user@example.com",
  "external_id": "string",
  "properties": {},
  "occurred_at": "2026-05-17T20:00:00Z",
  "idempotency_key": "string",
  "dedup_mode": "retry"
}
```

## Example request

```bash
curl -X POST 'https://api.sendops.dev/v1/activities' \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"string","email":"user@example.com","external_id":"string","properties":{},"occurred_at":"2026-05-17T20:00:00Z","idempotency_key":"string","dedup_mode":"retry"}'
```

## Responses

### 202 — Per-item admission results, in input order

Content type: `application/json`

```json
{
  "data": [
    {
      "index": 0,
      "status": "accepted",
      "duplicate": true,
      "activity_id": "00000000-0000-0000-0000-000000000000",
      "contact_id": "00000000-0000-0000-0000-000000000000",
      "reason": "invalid_name"
    }
  ]
}
```

### 401 — Missing, malformed, or unknown API key

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 403 — Key lacks the required scope or plan limit violated

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 422 — Query parameter or path value failed validation

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 429 — Per-org rate limit exceeded

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```

### 500 — Unexpected server-side failure. The `code` is `internal_error`. The
`request_id` field can be quoted to SendOps support to investigate.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ]
}
```
