App connections
The app your campaigns call: base URL, signing secret, and the signed app.action and app.ping requests.
An app connection is how a campaign reaches into your product. It is a name, an HTTPS base URL and a signing secret. An app_action step POSTs to a path under the base URL, signed with the secret, and the JSON object your app answers becomes .App.<output_key> for every later step in that contact's sequence. Product events flow the other way: your app calls POST /events (see product events).
Every route needs the Integrations scope and, for a session, the manage_settings permission. The secret is returned once, on create and on rotate, and never again.
List app connections
GET /app-connections
Auth: Scope INTEGRATIONS · Org permission manage_settings
{
"data": [
{
"id": "108178ff-8dee-4279-87f2-2df2a485ceb5",
"organization_id": "f9e8d7c6-...",
"name": "app",
"base_url": "https://app.example.com/sendsets",
"created_at": "2026-09-01T10:00:00Z",
"updated_at": "2026-09-01T10:00:00Z"
}
]
}Create an app connection
POST /app-connections
Auth: Scope INTEGRATIONS · Org permission manage_settings
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique within the workspace, up to 100 characters. |
base_url | string | yes | HTTPS. Query and fragment are dropped; a URL with credentials is refused. |
Response: 201 Created
{
"connection": { "id": "108178ff-...", "name": "app", "base_url": "https://app.example.com/sendsets", "created_at": "...", "updated_at": "..." },
"secret": "ss_app_wnR54NSd8vKbMcDRztk7yiiZLv-abjmve7Lhca4P_vo"
}Store the secret: it is what your app verifies requests with.
Get, update, delete
GET /app-connections/:id, PATCH /app-connections/:id (name, base_url), DELETE /app-connections/:id.
Auth: Scope INTEGRATIONS · Org permission manage_settings
A connection an app_action step still references cannot be deleted: 409 app_connection_in_use.
Rotate the secret
POST /app-connections/:id/rotate-secret
Replaces the signing secret and returns the new one once, as {"secret": "..."}. Requests signed with the old secret stop verifying immediately.
Test an app connection
POST /app-connections/:id/test
Sends a signed app.ping to the base URL and reports each stage of reaching the app. The answer is always 200: a failing app is a result, not a transport error.
Auth: Scope INTEGRATIONS · Org permission manage_settings
{
"dns": true,
"tls": true,
"connection": true,
"authentication": true,
"status": 200,
"latency_ms": 184
}authentication is true when the app answered any 2xx to the signed ping, which is the app saying it verified X-SendSets-Signature. Anything else fills error:
{
"dns": true,
"tls": true,
"connection": true,
"authentication": false,
"status": 401,
"latency_ms": 90,
"error": {
"code": "app_connection_failed",
"message": "the app answered HTTP 401 to a signed app.ping",
"fix": "Check the app is reachable at https://app.example.com/sendsets and verifies X-SendSets-Signature with the connection secret."
}
}What your app receives
Both the ping and a live app_action are a POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-SendSets-Event | app.ping or app.action |
X-SendSets-Event-Id | The step run id, stable across retries. Dedupe on it. |
Idempotency-Key | The same id. |
X-SendSets-Signature | t=<unix>,v1=<hex>: HMAC-SHA256 of <unix>.<raw body> with the connection secret, the same scheme as webhooks. |
An app.ping body is {"id": "ping:<uuid>", "run_mode": "test", "ping": true, "requested_at": "..."}; answer any 2xx.
An app.action body carries the campaign, the step, the contact (standard fields plus custom fields), the .App values captured so far, and the step's rendered inputs:
{
"id": "<campaign>:<contact>:<step>",
"run_id": "3f9c...",
"run_mode": "live",
"campaign": { "id": "...", "name": "Q3 founders" },
"step": { "id": "...", "name": "Audit" },
"contact": { "id": "...", "email": "jane@acme.com", "first_name": "Jane", "company": "Acme" },
"app": { },
"inputs": { "domain": "acme.com" },
"requested_at": "2026-09-01T10:00:00Z"
}Answer a 2xx with a JSON object of at most 64 KiB within 30 seconds. That object is stored as .App.<output_key>, so {"score": 67, "report_url": "https://..."} on a step with output_key: audit renders as {{.App.audit.score}} in a later email. A non-2xx, a non-object or a timeout ends the step with outcome error (app_action_failed), which a node_outcome branch can route; unrouted, the run is blocked and POST /campaigns/:id/step-runs/:runId/retry runs it again.