Append a campaign journey event
Appends one local event after checking campaign existence and any supplied codeId/placementId belongs to the same campaign/workspace. It does not prove a scan, booking, signup or provider outcome occurred.
Outcome and boundaries
Appends one local event after checking campaign existence and any supplied codeId/placementId belongs to the same campaign/workspace. It does not prove a scan, booking, signup or provider outcome occurred.
No idempotency key, update or deletion action exists; externalRef is a stored correlation string, not a deduplication guarantee.
Before you start
- Confirm the intended environment and current credential organization using whoami; discover the exact application/action before any call.
- Required application permission: app_topolo_campaigns.campaigns:write. Catalog discoverability is not permission to execute.
- Discover an authorized canonical workspace and bind resourceType workspace/resourceId separately from action input.
- All worked cases in this guide are hypothetical source-reviewed illustrations; no business action, live fixture, customer data or provider request was executed.
- Canonical workspace minimum access is read_write in addition to the application permission. After grant verification, the first request may create an app-local identity/recipient mirror even before the later app-permission check; this is not campaign-domain completion.
Discover the intended identifiers
- Read campaigns.get, codes.list and placements.list as applicable; use code-record ids and placement ids, not redeemable code text.
Action: app_topolo_campaigns.journey_events.record · POST /api/campaigns/{id}/journey-events.
Input fields
| Field | Required | Meaning and validation |
|---|---|---|
id | Yes | Campaign identifier from campaigns[].id; scoped to the selected organization/workspace. It is not a batch/code/placement id. string; minLength=1 |
eventType | Yes | Declared local journey category. A stored event does not establish a live conversion, booking or delivery. string; enum=["discovered", "scanned", "engaged", "claimed", "qualified", "booked", "redeemed"] |
codeId | No | Code-record id from codes[].id, distinct from the human code string. string |
placementId | No | Placement id from placements.list for this same campaign. string |
subjectRef | No | Optional opaque subject correlation string; no user lookup or verified identity is implied. string |
externalRef | No | Opaque correlation reference; not a unique constraint or proof of an external operation. string |
metadata | No | Stored JSON object. It is not an instruction, access grant or evidence of a provider outcome; avoid credentials or sensitive customer data. object |
occurredAt | No | Optional timestamp accepted when Date.parse succeeds; omission uses current time. The input schema only checks string type, so a schema-valid string can still fail at the handler. string |
Safe call sequence
Write the reviewed input to payload.json; fixture identifiers below are unusable examples. Validate before planning, bind the discovered resource separately, and execute only within actual authority.
topolo actions validate app_topolo_campaigns.journey_events.record --data @payload.json --json
topolo actions plan app_topolo_campaigns.journey_events.record --resource-type workspace --resource-id "$CAMPAIGNS_WORKSPACE_ID" --data @payload.json --json
The published action requires confirmation. Only after the exact mutation is authorized, use that same payload/context with --confirm on plan/call; no operation in this guide has been executed.
Worked cases
Every response below is synthetic and source-reviewed, not captured. HTTP status and body are explained independently; a structurally valid body alone never proves successful execution. Middleware may add a different error envelope.
Record a disclosed scan event · HTTP 201
Input:
{
"id": "campaign_example",
"eventType": "scanned",
"codeId": "code_example",
"placementId": "placement_example",
"externalRef": "scan_example",
"occurredAt": "2026-10-20T00:00:00.000Z"
}
Illustrative response:
{
"event": {
"id": "journey_example",
"codeId": "code_example",
"placementId": "placement_example",
"eventType": "scanned",
"subjectRef": null,
"externalRef": "scan_example",
"metadata": {},
"occurredAt": "2026-10-20T00:00:00.000Z",
"createdAt": "2026-10-20T00:00:00.000Z"
}
}
Invalid timestamp is a domain refusal · HTTP 400
Input:
{
"id": "campaign_example",
"eventType": "scanned",
"occurredAt": "not-a-date"
}
Illustrative response:
{
"error": "invalid_occurred_at"
}
Foreign code · HTTP 404
Input:
{
"id": "campaign_example",
"eventType": "scanned",
"codeId": "code_absent"
}
Illustrative response:
{
"error": "code_not_found"
}
Verify the outcome
- Read journey_events.list; match the event id, type, correlation references and occurredAt. Retain actual external evidence separately.
Refusals and recovery
- HTTP 404
campaign_not_found: Correct the named input or retain the authoritative refusal in the same context. - HTTP 404
code_not_found: Correct the named input or retain the authoritative refusal in the same context. - HTTP 404
placement_not_found: Correct the named input or retain the authoritative refusal in the same context. - HTTP 400
invalid_event_type: Correct the named input or retain the authoritative refusal in the same context. - HTTP 400
invalid_occurred_at: Correct the named input or retain the authoritative refusal in the same context. - HTTP 403
forbidden: Raw handler permission refusal. SDKs can normalize it to permission_denied. Keep the same intended resource; do not widen access. - HTTP 403
workspace_forbidden: The canonical workspace grant refused the selected resource. Resolve it with the access owner, not a guessed workspace.
No deletion or exact undo is published for this record. Preserve the returned identifier and inspect the corresponding list before considering another separately authorized operation. A timeout does not prove that nothing was written.
Source and catalog limits
The catalog suggests a compensating event. Source has no special correction or retraction event semantics; an extra event is simply another record and must not be presented as undo.
Exact schemas and provenance
Input schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"eventType": {
"type": "string",
"enum": [
"discovered",
"scanned",
"engaged",
"claimed",
"qualified",
"booked",
"redeemed"
]
},
"codeId": {
"type": "string"
},
"placementId": {
"type": "string"
},
"subjectRef": {
"type": "string"
},
"externalRef": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {},
"additionalProperties": {}
},
"occurredAt": {
"type": "string"
}
},
"required": [
"id",
"eventType"
],
"additionalProperties": false
}
Output schema:
{
"type": "object",
"title": "journey events record response",
"description": "Operation result returned by journey_events.record.",
"properties": {
"success": {
"type": "boolean"
},
"id": {
"type": "string"
},
"data": {
"anyOf": [
{
"type": "object",
"title": "journey record",
"properties": {
"id": {
"type": "string",
"description": "Stable identifier when the journey exposes one."
}
},
"additionalProperties": true
},
{
"type": "object",
"additionalProperties": true
}
]
},
"result": {
"type": "object",
"additionalProperties": true
},
"message": {
"type": "string"
}
},
"additionalProperties": true,
"x-topolo-contract-source": "derived-action-metadata",
"x-topolo-contract-precision": "structural"
}
Reviewed immutable source: TopoloCampaigns@cacbb8b12b4e19a662cf82d03a927aef3c3a35e8, apps/api/src/campaign-orchestration-store.ts. Fresh production catalog was read on 2026-10-20. Its publication metadata is not proof that this source is loaded. Output examples are not widened or rewritten to force schema success; the accompanying validation record classifies exact success, refusal, no-body and structural-schema limits.
Related actions
- List campaign journey events — Campaigns: Lists stored journey events for the campaign, newest occurrence first. Subject and external references are stored attribution, not executed follow-ups. An absent campaign can return an empty list.
- Get campaign dashboard — Campaigns: Reads stored batch, code, placement and redemption counts plus counts for each journey event type. Resource identity is the request id; the dashboard response contains counts. Stored events do not establish a live provider outcome.
Local input rejection example
This deliberately invalid input belongs to a pure schema check; it is never a planned or executed request. The closed action input schema must reject the extra field. It has no HTTP status or server response.
{
"id": "campaign_example",
"eventType": "scanned",
"codeId": "code_example",
"placementId": "placement_example",
"externalRef": "scan_example",
"occurredAt": "2026-10-20T00:00:00.000Z",
"__unsupported_field": true
}
The hidden companion labels this as ILLUSTRATIVE_LOCAL_SCHEMA_CHECK; its validation representation is not an action output.
Published output validation limits
Current published output qualification: the output schema accepts an empty object, a partial success marker, a partial data object and a refusal body. It therefore establishes only broad structure. Require the documented HTTP/domain result and source-shaped fields; missing fields or a schema-valid error are not success.
These observations come from pure checks of the exact published schema; they are not server responses or production captures.