# Imports, errors, and retries

Use the Core API to import notes and communication email logs. Batch submission queues work; a `202` response does not
mean rows were written. The [OpenAPI reference](https://api.quake.dev/openapi.json) defines the current payloads.

## Choose the write operation

| Resource | Operation | Matching key | Scope to submit / poll |
| --- | --- | --- | --- |
| One note | `POST /core/notes` creates; `POST /core/notes/upsert` creates or updates | Dataset-scoped `external_id` for upsert | `notes:write`; reads require `notes:read` |
| Notes batch | `POST /core/notes/batches`, `operation: "create"` or `"upsert"` | `external_id` required on every upsert row | `notes:write` / `notes:read` |
| Email logs batch | `POST /core/email-logs/batches`, `operation: "upsert"` only | Dataset-scoped `remote_id` | `integrations:write` / `integrations:read` |

Each batch contains 1–5,000 rows. Each batch note supports up to 20 record links. These limits do not apply uniformly to
single-record endpoints. There is no public email-log batch `create` mode.

Use stable source keys, preferably with a source namespace. The email `provider` is metadata; it does not namespace the
upsert key. Quake record IDs, note `external_id`, email-log `remote_id`, and email `message_id`/`thread_id` have different
roles. Save the returned Quake IDs and the source-to-Quake mapping.

## Submit notes

```json
{
  "operation": "upsert",
  "notes": [
    {
      "external_id": "ats-note-1024",
      "title": "Intake debrief",
      "content": "Candidate can interview next Tuesday.",
      "created_at": "2026-08-01T09:30:00.000Z",
      "records": [{ "id": "ats-candidate-42", "type": "candidate", "is_remote": true }]
    }
  ]
}
```

```bash
curl 'https://api.quake.dev/core/notes/batches' \
  --header 'Authorization: Bearer <token>' \
  --header 'Content-Type: application/json' \
  --data-binary @notes-batch.json
```

Save the JSON payload above as `notes-batch.json`. Persist the `batch_id` from the returned state before polling
`GET /core/notes/batches/{batch_id}`. Polling requires a read scope as well as the write scope used to submit.

Links with `is_remote: true` resolve source IDs for candidate, job, client, and contact records. A missing remote record
may remain an unresolved remote link for later reconciliation; do not treat a successful import as proof every link
resolved. Batch `created_by` can identify a dataset user with `{ "type": "user", "id": "<dataset-user UUID>" }`, resolve
one by `{ "type": "user_remote_id", "id": "<source-user ID>" }`, or use `{ "type": "system", "id": "import" }`.
Unknown/ambiguous authors fail validation before queueing. These are dataset-user IDs, not global login-user IDs.

Batch note writes are not general partial updates: `content` is required, missing titles become `Untitled`, and bulk
upsert replaces record links; omitted `records` is treated as an empty list and clears existing links. Supplied
`tag_assignments` replaces all assignments; omitted assignments preserves them on existing notes. Author changes require
`update_created_by: true`; supply the intended author override as well. Include the complete intended linked-record set
when recovering a row. Single-note update/upsert contracts must be checked separately.

## Submit email logs

```json
{
  "operation": "upsert",
  "email_logs": [
    {
      "remote_id": "ats-email-8842",
      "record_type": "candidate",
      "record_remote_id": "ats-candidate-42",
      "provider": "ats",
      "from_email": "recruiter@example.com",
      "to": [{ "email": "candidate@example.com", "name": "Candidate Name" }],
      "subject": "Interview update",
      "body_text": "The candidate confirmed the interview time.",
      "created_at_provider": "2026-08-01T09:30:00.000Z",
      "status": "sent"
    }
  ]
}
```

Submit to `POST /core/email-logs/batches` and poll `GET /core/email-logs/batches/{batch_id}`. These rows import stored
communication history; they do not send email. Link each row to a candidate or contact through a Quake `record_id` or
source `record_remote_id`. The linked record must resolve; missing or ambiguous references fail the row. Supply at least
one nonblank `body_text` or `body_html` and the source timestamp.

Default `addressing_mode: "explicit"` requires `from_email`. Alternatively,
`addressing_mode: "linked_record_and_dataset_user"` requires `dataset_user_remote_id` and resolves addresses from the
linked record/user. In that mode omit `from_email`, `from_name`, and `to`. See the schema for all field limits.

Email upsert maps a complete row. Omitted subject/body variants, message/thread IDs, variables, and metadata normalize to
`null`; `to`, `status`, and `is_open` also have defaults. Reusing a remote ID can therefore clear or replace fields. Resend
the intended full source mapping when correcting a row, rather than a small patch.

## Poll and inspect every row

The status response is `{ "state": ..., "results": ... }`. `results` is initially `null`; after processing it contains
another batch summary plus the per-row `results` array. Each row has a zero-based input `index`, `status` (`created`,
`updated`, or `failed`), the source key, and optional Quake `id` or `error`. Store your original input order.

| State | Meaning and next step |
| --- | --- |
| `queued` | Accepted for worker processing; continue polling with bounded backoff |
| `running` | A worker claimed the batch; continue polling |
| `completed` | Processing finished; inspect `failed` and every row outcome |
| `failed` | A batch-level processing/handoff error occurred; inspect `state.error`, verify written records, and monitor state before resubmitting |

**Completed can include failed rows.** A successful poll or `state.status === "completed"` is not an all-rows-success
check. Batch failures can return an empty row-results array even if earlier chunks wrote data. Notes use transactional
chunks of 500; a batch is not one atomic transaction. Email rows are processed independently. Automatic worker redelivery
may retry a failed batch, so `failed` is not a guarantee that no further processing will occur.

Duplicate source keys inside a request fail later duplicate rows. Notes `create` also reports an existing external-ID
conflict as a failed row (`external_id already exists`). Notes without external IDs have no source-key deduplication.
Use upsert deliberately when overwriting an existing source record is intended; do not silently convert create conflicts
into updates.

## Recover without blind mutation retries

1. For `400` validation errors, correct the payload, JSON encoding, or query; retrying unchanged input will not help.
2. For `401`/`403`, repair credentials, scopes, or visibility before retrying. A missing/hidden record or expired batch can
   return `404`; check the ID, dataset, and retention before concluding it was never created.
3. Retry read-only status requests after transient network/server errors with bounded exponential backoff and jitter.
   Honor `Retry-After` when supplied; its presence is not guaranteed.
4. After an acknowledged batch, poll its existing ID. Recover only confirmed failed rows after correcting their causes,
   keeping the original stable source keys and full intended values. Keep successful row mappings.
5. After a timed-out submit or batch-level failure, reconcile the known batch and resulting records first. The public
   batch request has no idempotency key, and each new submission creates a new batch. An upsert matching key limits some
   duplicate-record risks; it does not promise exactly-once side effects or safe replay of stale content.

Branch on HTTP status and operation-specific error payloads. Not every error has a uniform string `error` field; retain
`message`/details and a redacted request correlation in your logs. Do not log tokens or full note/email bodies. There is
no public manual replay endpoint; worker retry/lease behavior is an internal recovery mechanism, not a client replay API.

## Retention and reconciliation

Batch payload, state, and results use S3 storage under `openapi-batches/`, with a seven-day lifecycle. `expires_at` is
metadata, not an exact-time deletion promise. Archive row results and source/Quake ID mappings before expiry; polling
becomes unavailable after the objects are removed. This retention concerns batch artifacts, not a promise to delete
imported notes or email logs. Search indexes update asynchronously, so an immediate missing search result does not prove
a failed write. See [search and pagination](https://api.quake.dev/documentation/guides/query-and-pagination).
