---
title: "Engagement"
description: "Bringing a migrated blog's engagement history with it: per post per day views, reactions and share clicks, and each reader's current reaction. Recording live engagement and reading an article's counters are under SEO."
canonical: "https://writavo.com/docs/api/engagement"
last-updated: "2026-09-24"
---

# Engagement

Bringing a migrated blog's engagement history with it: per post per day views, reactions and
share clicks, and each reader's current reaction. Recording live engagement and reading an
article's counters are under SEO.

Base URL: `https://api.writavo.com/v1`

### POST /engagement/import

Import engagement history

- **Operation id**: `importEngagement`
- **Scope**: `engagement:write`
- **Permission**: `articles.write`
- **Rate limit class**: write
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Bring a blog's engagement history to Writavo when it migrates: views, reactions and share
clicks per article per day, and each reader's current reaction. Articles are matched by
`external_id` or `slug` on this Site, in any status. Call it from a server with a secret key.

**SET, never add.** A `daily` row replaces that article's counts for that day: each field
you send (`views`, `reactions`, `shares`) replaces that field, a field you leave out keeps
its stored value (0 on a new day), and `shares` replaces all four platforms (a platform you
do not name becomes 0). A `reactions` row replaces that reader's current reaction on that
article. Sending the same payload twice changes nothing, so a retried or repeated import can
never double-count.

**Which days.** Any UTC day from 1990-01-01 up to yesterday, including days that already
have live counts: the import replaces them. Today is refused only because Writavo rewrites
today's counts from the live events every hour, so an import for today would be overwritten
within the hour; import it tomorrow. `set_at` on a reaction may be any past instant.

**Reactions.** Writavo keeps a per-day reaction TOTAL and, separately, one current reaction
per reader (which is what the per-type counts are made of). A `daily` row's `reactions` may
be a number or per-type counts, which are summed. An imported reader's reaction replaces a
live one and moves the day totals like a live change does (one off the day the old reaction
was made, one onto the day of `set_at`). Daily rows are applied after reactions, so a day
whose total you send explicitly ends at exactly that number.

**Dry run first.** `dry_run` defaults to true: every row is checked and matched, `posts`
and `problems` are reported, and nothing is written. Send `dry_run: false` to write. A row
with a problem is skipped and listed in `problems`; the valid rows are still written.

Imported rows are marked as imported internally and count everywhere exactly like live
engagement: the public counters, the dashboard and `GET /seo/engagement`. `visitor_id` is
hashed with the Site's id before it is stored, the same way `POST /engagement/events` does,
so a reader keeps their imported reaction when they come back. Each applied import is
recorded in the audit log with its counts.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | string | yes | A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A UUID is the obvious choice. Retry with the **same** key and the same body and you get the original response replayed rather than a second object. This is what makes a network timeout safe: you never know whether the first request landed, so you retry with the same key and find out. Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a key for different work is a bug in your client rather than a retry. Same key while the first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry. Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that the same key is a new operation. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `dry_run` | boolean | no | Check and report without writing. Defaults to true; send false to write. |
| `daily` | EngagementImportDay[] | no | Per article per UTC day, views, the reaction total and shares per platform. |
| `reactions` | EngagementImportPick[] | no | Each reader's current reaction per article. One row per reader and article. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | EngagementImportResult | The report. With `dry_run` it describes what would be written. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 401 | - | `INVALID_API_KEY`, `API_KEY_REVOKED` or `API_KEY_EXPIRED`. There is no key, or it is not usable. This response never distinguishes "no such key" from "wrong key", so a caller cannot probe for valid keys. |
| 403 | - | `INSUFFICIENT_SCOPE`. The key is valid but does not carry the scope this operation requires, or its creator's permissions no longer cover it. It is never returned for an object belonging to another Site: that case is always 404, so this response never reveals that something exists. For a key an AI agent holds, a 403 may also be `AGENT_ACCESS_DISABLED` (the organisation turned agent access off) or `APPROVAL_DENIED` (a person denied this action). Neither is about scope, and neither reveals anything about another Site. On the organisation, team and settings operations a 403 may also be `FORBIDDEN`: a change no scope allows, such as an AI agent changing the access of the person it acts for, anything to do with ownership, or a setting only a person may change. The message says what to do instead. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. On an approval-gated operation it may also be `APPROVAL_INVALID`: the `Writavo-Approval` you sent is expired, used, for a different request, unknown, or what the request would do has changed since it was approved. Retry without it to ask for a new approval. On a settings, delivery or SEO operation it may be `PREREQUISITE_MISSING` (something the operation needs is not set up; the message names the operation that sets it up) or `FEATURE_UNAVAILABLE` (Writavo has the capability switched off; the message names the free alternative). Neither did anything or charged anything. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money, as do the paid SEO scans and the other operations that do expensive work. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/engagement/import \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "daily": [
    {
      "external_id": "blog:1",
      "day": "2026-05-10",
      "views": 120,
      "reactions": {
        "useful": 2,
        "insightful": 1
      },
      "shares": {
        "x": 1,
        "linkedin": 3
      }
    },
    {
      "slug": "how-to-choose-a-headless-cms",
      "day": "2026-05-11",
      "views": 87
    }
  ],
  "reactions": [
    {
      "external_id": "blog:1",
      "visitor_id": "anon_7f3a9c",
      "reaction": "useful",
      "set_at": "2026-05-10T14:03:00Z"
    }
  ]
}'
```
