---
title: "Record one verified real-world execution of a prepared outreach, or a factual CORRECTION of an earlier one. Never resolves or signals an Action. Absence of a bounce is not delivery proof."
method: POST
path: "/outreach/{id}/executions"
---

# Record one verified real-world execution of a prepared outreach, or a factual CORRECTION of an earlier one. Never resolves or signals an Action. Absence of a bounce is not delivery proof.

`POST /outreach/{id}/executions`

## Request

- Base URL: `https://forbiz.io/api/public/agent`
- URL: `https://forbiz.io/api/public/agent/outreach/{id}/executions`
- Auth: HTTP bearer

Two distinct operations share this endpoint.

NORMAL EXECUTION (no supersedesExecutionId): identity is externalSource + externalBatchId. A repeat answers 208 with the already recorded execution.

CORRECTION (supersedesExecutionId present): this is NOT a replay of the superseded row, even with the SAME externalBatchId — a correction fixes what Forbiz knows about the same real-world batch and is never a second send. It requires correctionNote, and its external execution identity must match the record being corrected. Its idempotency identity is the correction operation: outreach + superseded execution + Idempotency-Key (falling back to a hash of the correction). Retrying the same correction answers 208 with the existing correction, never the original. History keeps the superseded row; effective state, reconciliation and preflight all use ONE canonical selector: a record superseded by a later one is history, and of the records sharing one external execution identity only the newest is effective. In a chain original -> correction 1 -> correction 2 only the current effective record contributes to aggregates, so a chain can never inflate totals. Callers never supply any correction identity field themselves: the correction operation key is derived internally from the outreach, the superseded execution and the Idempotency-Key.

## Path parameters

- `id` string, required

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `channel` 'email' | 'phone' | 'linkedin' | 'meeting' | 'other'
  - `executedAt` string, date-time: Required. When the send actually OCCURRED (alias: occurredAt). When Forbiz recorded it is stored separately as recorded_at and is never caller-supplied.
  - `executionType` 'initial' | 'follow_up' | 'resend' | 'duplicate' | 'unknown' | 'other': Classification of this execution. Defaults to 'unknown' — never guessed by Forbiz.
  - `executionSource` string
  - `externalSource` string
  - `externalBatchId` string: Canonical external execution identity together with externalSource: one NON-CORRECTION execution per outreach + (source:batch id). A replay answers 208 with the already recorded execution. A correction repeats the SAME value on purpose.
  - `supersedesExecutionId` string: Correction: records a NEW execution row that supersedes an earlier one on this outreach. Bypasses external-batch replay detection; requires correctionNote and a matching external execution identity. History is never rewritten or deleted, and no second real-world send is implied.
  - `correctionNote` string: Required when supersedesExecutionId is present. State factually what was wrong in the superseded record.
  - `automationMode` 'autonomous' | 'manual': Defaults to manual (human-approved). 'autonomous' means no human approved this specific send, and it MUST pass the hard autonomous send budget: one slot per confirmed recipient is reserved atomically before the execution row is written. Exhausted budget answers 429 with code AUTONOMOUS_SEND_LIMIT_REACHED and execution_allowed=false plus limit/used/remaining/window_end. Retrying the SAME logical send (same Idempotency-Key, or same external source + batch id) reuses the original reservation and consumes nothing extra; a genuinely new send consumes a new slot. Corrections (supersedesExecutionId) never consume budget because they are not a second real-world send. A granted slot is not released automatically if the external send later fails — releasing on failure would allow an unbounded retry loop.
  - `sender` string
  - `recipientCount` integer
  - `actualSubject` string
  - `actualBodySnapshot` string
  - `preparedSubjectSnapshot` string
  - `preparedBodySnapshot` string
  - `preparedTemplateId` string
  - `executionIntegrity` 'matched' | 'modified' | 'unknown'
  - `integrityNote` string: Required when executionIntegrity is 'modified'.
  - `targets` object[]: Each item MUST identify its recipient by campaignTargetId OR by recipientAddress (at least one; both is fine). A recipientAddress-only row is first-class send evidence: it is stored as an execution target row with campaign_target_id null, it counts in effective state (counts.sent_confirmed_targets counts confirmed RECIPIENTS, identified by target id or normalized address) and in global recipient safety (a confirmed address-only send inside the duplicate window produces global_duplicate_recent_send and appears in prior_executions). Address-only rows do NOT create campaign_target_events and do NOT update campaign_targets.status, because those require a target; bounce or reply events for such a recipient need a campaign target or a person first. Addresses are compared on their normalized form (trimmed, lowercased); two rows for the same address in one execution collapse into one. Validation runs BEFORE any write: if an item carries neither a valid uuid campaignTargetId nor a usable recipientAddress, the call answers 400 with { error: "targets[<i>]: campaignTargetId or recipientAddress is required", invalid_targets: [{ index, reason }] } and NOTHING is written — no execution header is left behind. The literal string "undefined" is treated as an absent value. The response reports target_execution_rows and address_only_rows, plus sent_events: { attempted, written, error }. sent_events describes the per-recipient 'sent' event ledger write for confirmed id rows: it is idempotent per (campaign_target_id, external_source, external_message_id, event_type), and a failure is REPORTED, never fatal — the execution row and its recipient rows are already valid facts, so the call still answers 201 with sent_events.error set and the failure logged server-side.
    - `campaignTargetId` string: Optional. A campaign target uuid belonging to this outreach. Required only when recipientAddress is absent. A non-uuid value is rejected with 400.
    - `recipientAddress` string: Optional. The real recipient address. Required only when campaignTargetId is absent; then this row is address-only send evidence.
    - `sentAt` string, date-time
    - `externalMessageId` string
    - `externalThreadId` string
    - `executionStatus` 'sent_confirmed' | 'send_possible_unconfirmed' | 'no_send_evidence' | 'not_contactable'
    - `deliveryStatus` 'unknown' | 'no_bounce_observed' | 'hard_bounce' | 'soft_bounce' | 'delivery_confirmed'

## Response `201`

Execution or correction recorded

- object
  - `ok` boolean
  - `execution` object
  - `is_correction` boolean
  - `supersedes_execution_id` string

## Other responses

- `208`: Idempotent replay of a previous request with the same Idempotency-Key. Nothing was applied again.
- `400`: Validation error: targets outside this outreach, missing correctionNote, unknown supersedesExecutionId, or external execution identity that does not match the corrected record
- `404`: Not found

---

[API](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api.md) · [All operations](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api/llms.txt) · [OpenAPI document](https://skmtc.dev/forbiz/apis/forbiz-external-agent-api/revisions/e5b10d59dc5f?raw)
