---
title: "Apply a tag or list change to every contact matching a filter"
method: POST
path: "/contacts/bulk-actions"
tags: ["Contacts"]
---

# Apply a tag or list change to every contact matching a filter

`POST /contacts/bulk-actions`

Queues one action against every contact the filter matches, applied in chunks in the
background. Returns immediately with an id to poll.

**Run with `dry_run: true` first.** A dry run reports how many contacts match and changes
nothing — it is the only way to catch a filter that matches more than intended, and a bulk
write is not undoable. An empty filter (which would match the whole account) is rejected
unless it is a dry run.

Per-campaign conditions (`opened_campaign_id`, `not_opened_campaign_id`,
`clicked_campaign_id`) are available here but not on `GET /contacts`, because they are
resolved in the background rather than inside a request.

Bulk delete and bulk unsubscribe are deliberately not offered.

## Request body

- object
  - `action` 'apply_tag' | 'remove_tag' | 'add_to_list' | 'remove_from_list', required
  - `target_id` integer, required — The tag id (apply_tag/remove_tag) or list id (add_to_list/remove_from_list)
  - `dry_run` boolean — When true, only count the matches — nothing is modified
  - `filter` ContactFilter — An engagement filter over the account's contacts. Every condition is AND-ed. Omitting the object entirely means "all contacts".
    - `status` 'active' | 'engaged' | 'inactive' | 'new' | 'unconfirmed' | 'unsubscribed' | 'bounced' | 'invalid'
    - `last_opened_after` string, date-time
    - `last_opened_before` string, date-time — Excludes contacts who never opened — use never_opened for those
    - `last_clicked_after` string, date-time
    - `last_clicked_before` string, date-time
    - `last_sent_after` string, date-time
    - `last_sent_before` string, date-time
    - `created_after` string, date-time
    - `created_before` string, date-time
    - `never_opened` boolean
    - `never_clicked` boolean
    - `never_sent` boolean
    - `in_list_ids` integer[]
    - `not_in_list_ids` integer[]
    - `tag_ids` integer[]
    - `not_tag_ids` integer[]
    - `opened_campaign_id` integer — Bulk actions only — not available on GET /contacts
    - `not_opened_campaign_id` integer — Contacts the campaign sent to who did not open it. Bulk actions only — not available on GET /contacts
    - `clicked_campaign_id` integer — Bulk actions only — not available on GET /contacts

## Response `202`

Queued

- BulkContactAction
  - `id` integer
  - `status` 'pending' | 'processing' | 'completed' | 'failed'
  - `action` 'apply_tag' | 'remove_tag' | 'add_to_list' | 'remove_from_list'
  - `target_id` integer
  - `dry_run` boolean
  - `filter` string — Human-readable rendering of the filter that was applied
  - `matched_count` integer, nullable — Number of contacts the filter matched; null until counted
  - `processed_count` integer
  - `error` string, nullable
  - `started_at` string, date-time, nullable
  - `completed_at` string, date-time, nullable

## Other responses

- `403` — The account is restricted
- `422` — Invalid filter, an unowned target or filter id, or an empty filter on a non-dry run

---

[API](https://skmtc.net/sendfox/apis/sendfox-api.md) · [All operations](https://skmtc.net/sendfox/apis/sendfox-api/llms.txt) · [OpenAPI document](https://skmtc-service-staging.skmtc.workers.dev/v1/apis/sendfox/sendfox-api/revisions/c5728da3037f/schema)
