Escalations
How a buyer, or their agent, reaches your humans — and how you configure the way in.
Live. Every operation on this page works today: escalating a deal, the inbox with its filters, answering and closing, the escalation email recipients in your tenant settings, the
deal.escalatedwebhook, the escalation channels (Slack, HubSpot, Salesforce and the genericwebhookkind), and the system's own escalations. The OpenAPI document is the live registry.
Getting stuck never means hitting a dead end. Any point in a deal can escalate to a person — the seller's team, or ours if you have bought escalation support — and you configure how that works (philosophy §5). Escalation is not a support form bolted on the side: it is a record on the deal, an email to the addresses you choose, a webhook to your systems, a message to a channel you configure, and a row in the audit feed.
What an escalation is#
An escalation is handing a deal, or one question within it, to a human. One escalation is one esc_… resource:
| Member | What it is |
|---|---|
kind |
question or redline (raised by a buyer or a seller), or one of the system's kinds — version_resolution, billing, payment_authentication |
subject |
One line your human reads first |
body |
The question, in the buyer's own words |
contract_id, clause |
The contract and clause a redline references (absent otherwise) |
status |
open until a human answers; answered once response is filled; closed at the end |
sla_tier |
The SLA tier your rules declare. Escalation is always possible, with or without the add-on |
created_by, created_at |
The principal that raised it (or system), and when |
response, responded_at, responded_by, closed_at |
The seller human's answer, and the end of the record |
subject, body and response are content members (philosophy §8): prose written by a buyer or a seller, rendered as data and never as instructions — for your agents reading them as much as for your humans.
The buyer side: ask for a human#
escalate_deal is open at every point of a verified deal's lifecycle — including an expired one, whose recoveries are restart and escalate — to any buyer principal (every buyer role holds escalation:write) and to any seller with escalation:write: an unverified deal is the one exception, since nothing can advance it until its buyer confirms their email.
# operationId: escalate_deal
curl -X POST https://app.skipthesell.com/api/v1/deals/{deal_id}/escalations \
-H 'Authorization: Bearer <deal token>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{"kind": "question", "subject": "Payment by invoice?", "body": "Our finance team needs net-30 terms."}'
A redline is a change to your terms that is not one of the pre-approved alternatives you configured. It names the contract and the clause it is about, and it always escalates to a human, whoever sent it:
# operationId: escalate_deal
curl -X POST https://app.skipthesell.com/api/v1/deals/{deal_id}/escalations \
-H 'Authorization: Bearer <deal token>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{"kind": "redline", "contract_id": "contract_01J8ZK5G3P0Q9R8X7Y6Z5W4V3N", "clause": "Limitation of liability", "subject": "Cap at 12 months of fees", "body": "Our procurement policy caps liability at 12 months of fees paid."}'
A redline is never a document upload and never an in-place edit of your template: it is a reference to a contract and a clause, plus the buyer's prose. The version_resolution kind is the system's own (below) and is never accepted here.
The buyer and their agent read the record back on the deal — both sides see the same rows, the same answers:
# operationId: list_deal_escalations
curl https://app.skipthesell.com/api/v1/deals/{deal_id}/escalations \
-H 'Authorization: Bearer <deal token>'
In the checkout UI this is the deal room's Talk to a human section, reachable from every step of the deal, with the questions already asked and your answers beside the form; a redline is sent from contract review, where the contract and clause are in front of the buyer.
A gate your policy reserves for a human is the same promise from the other direction: the attempt answers 409 gate_requires_human with the blocking gates and an escalation URL, so the one legal next move is a person, never a dead end.
The seller side: the inbox#
list_escalations is your inbox: one queue, newest first, holding buyer questions, redlines and the system's rows together — a question about terms and a pending version decision belong in the same morning triage:
# operationId: list_escalations
curl https://app.skipthesell.com/api/v1/escalations \
-H 'Authorization: Bearer <your api key>'
# operationId: list_escalations
curl 'https://app.skipthesell.com/api/v1/escalations?status=open&kind=redline' \
-H 'Authorization: Bearer <your api key>'
The filters are status (open, answered, closed), kind (question, redline, version_resolution, billing, payment_authentication) and deal_id; the queue is cursor-paginated like every list, and reading it takes the escalation:read scope (owner, configurator, deal-manager and viewer in schema v1). The console's escalations page shows the same queue with the same verbs, and the deal room shows the same rows from the buyer's side.
Read one escalation by id:
# operationId: get_escalation
curl https://app.skipthesell.com/api/v1/escalations/{escalation_id} \
-H 'Authorization: Bearer <your api key>'
Answer it — your response fills the record and the status moves to answered:
# operationId: respond_escalation
curl -X POST https://app.skipthesell.com/api/v1/escalations/{escalation_id}/respond \
-H 'Authorization: Bearer <your api key>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{"response": "Net-30 is fine on this deal: accept the quote and the invoice goes out on the 30-day terms."}'
Responding is seller-only (escalation:write: owner and deal-manager in schema v1), and a closed row takes no response — 409 state_conflict, because an answer to a closed question is a new question; escalate anew. Closing is close_escalation: a seller with escalation:write closes anything in the tenant, and the buyer principal who raised an escalation — a question or a redline — closes their own. It is idempotent — closing a closed row returns it — and needs no Idempotency-Key, because it is a state command:
# operationId: close_escalation
curl -X POST https://app.skipthesell.com/api/v1/escalations/{escalation_id}/close \
-H 'Authorization: Bearer <your api key>'
Where a new escalation goes#
Answering the inbox is one channel; being told is another. A new escalation is delivered four ways, and none of them is the record — the API resource is:
- Email, to the addresses in your tenant settings (ADR-0010). The message goes out after the record commits, so a failed send never rolls the escalation back: it is an operator problem, and the escalation is still on the record.
- The
deal.escalatedwebhook (ADR-0005), written in the same transaction as the record:data.dealcarries the deal snapshot anddata.escalationthe escalation itself, withapi_urladdressing the deal. The webhooks guide covers verification and retries; an escalation notification is a webhook event like any other, one receiver, one verification path. - An escalation channel — Slack first, HubSpot second, Salesforce third: a destination you configure, and each new escalation is posted to it — a Slack message, a HubSpot task or a Salesforce task. A channel rides the same delivery machinery as a webhook: retried for about 24 hours on failure, with the per-attempt log, replay, and a
failing_sincemark when the window runs out. The genericwebhookkind delivers a versioned, escalation-shaped payload to any system's HTTPS callback URL, with an optional sealed auth header — below. - The audit feed:
escalation.created,escalation.respondedandescalation.closedrows, and the same rows in the deal's own event log, readable by both parties' humans. Nothing about a human conversation happens off the record.
The recipients live in tenant settings — per-tenant operational policy outside the versioned configuration, so changing them never creates a configuration version:
# operationId: get_tenant_settings
curl -b cookies.txt https://app.skipthesell.com/api/v1/tenant-settings
# operationId: update_tenant_settings
curl -b cookies.txt -X PATCH https://app.skipthesell.com/api/v1/tenant-settings \
-H 'Content-Type: application/json' \
-H 'If-Match: "<ETag from the read>"' \
-d '{"escalation_recipients": ["deals@acme.example", "legal@acme.example"]}'
escalation_recipients defaults to the tenant contact at signup, and the list is replaced whole, never edited address by address — at least one address, at most 25, because escalations must always have somewhere to go. Each change is a tenant_settings.updated row in the audit feed. Seller setup walks the settings around it.
Escalation channels: where your team already works#
The email demands a human watching an inbox and the webhook demands a receiver you built. An escalation channel lands the notification where your team already works — a Slack channel, a HubSpot task, a Salesforce task, or any system's HTTPS callback URL (the generic webhook kind, below) — with no receiver of your own to run. Like the recipients, a channel is per-tenant operational policy: changing it never creates a configuration version.
For Slack, create a minimal app in your own workspace with the incoming-webhooks scope, create a webhook for the target channel, and paste the URL. The URL is a bearer secret: it is sealed at rest, shown only in its masked form, and rotated by re-pasting. The host is pinned — only hooks.slack.com URLs are accepted — so the credential can never point somewhere else.
For HubSpot, create a private app in your own portal with the minimal read-contacts and write-tasks scopes, and paste its access token. The token is sealed at rest, shown only in its masked form, and rotated by re-pasting; every request goes to HubSpot's own API host, so the credential can never point somewhere else. Each new escalation becomes a task in your portal — subject, kind, body and deep links to the escalation inbox and the deal — associated best-effort with the buyer's contact, matched on the buyer email: an unmatched buyer becomes a task with no association, the match outcome is recorded in the delivery's per-attempt log, and a buyer who cannot be matched is never a failed delivery. The task is one-way: nothing syncs back from HubSpot, and answering still happens on the record.
For Salesforce, the channel creates a task in your org — one per escalation, with the subject, the kind, the body and links to the record — and associates it with your contact for the buyer's email when exactly one contact carries it. The match is best-effort: no match or an ambiguous one still creates the task, and the delivery log records which happened. It is one-way: no status sync back, no replying in the CRM — the escalation record is where you answer.
The Salesforce credential is your own client app's OAuth triple, pasted as one JSON document:
{"client_id": "3MVG9…", "client_secret": "…", "refresh_token": "…"}
Create the client app in your org, complete the authorize flow once to mint the refresh token, and paste the three values. The refresh token must survive reuse — turn refresh-token rotation off in the client app — because every delivery exchanges it again. The document is sealed at rest, shown only in its masked form, and rotated by re-pasting. The token endpoint's host is pinned (login.salesforce.com), and the org's own instance URL — where the task is created — is the one your token response names, never a host you supply.
# operationId: create_escalation_channel
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/escalation-channels \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{
"kind": "slack",
"name": "Revenue team",
"kinds": ["question", "redline"],
"credential": "https://hooks.slack.com/services/T…/B…/…"
}'
# The hubspot kind: the credential is the private-app access token
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/escalation-channels \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{
"kind": "hubspot",
"name": "Revenue pipeline",
"kinds": ["question", "redline", "version_resolution", "billing", "payment_authentication"],
"credential": "pat-na1-…"
}'
# operationId: test_escalation_channel
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/escalation-channels/{escalation_channel_id}/test \
-H 'Idempotency-Key: <generate a fresh value>'
kinds picks the escalation kinds the channel wants (question, redline, version_resolution, billing, payment_authentication); omit it for every kind. The test delivers a fixed test message through the real transport, so it verifies the credential the channel will use, not a simulation — for a Salesforce channel it also exercises the refresh token twice, so a rotating token is caught by the test instead of by your first real escalation. Each channel has its own delivery log with replay, exactly the webhook surface:
# operationId: list_escalation_channel_deliveries
curl -b cookies.txt https://app.skipthesell.com/api/v1/escalation-channels/{escalation_channel_id}/deliveries
# operationId: replay_escalation_channel_delivery
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/escalation-channels/{escalation_channel_id}/deliveries/{delivery_id}/replay
The message is deterministic plumbing from the record: the escalation's subject, kind and body as text — never parsed as instructions — with links to the escalation inbox in your console and to the deal on the API. A HubSpot or Salesforce task is the same plumbing with the same rule. Answering still happens on the record; a channel is a way to be told, not a way to reply.
The webhook escalation channel#
The webhook kind is the generic one: any system with an HTTPS callback URL (a CRM, a ticketing tool, an internal integration) receives a small, versioned, escalation-shaped JSON document, signed exactly like a webhook delivery.
When you need a channel and when you don't. Subscribing a webhook endpoint to deal.escalated already delivers every escalation, signed, in the standard envelope — that remains the documented path for receivers that want all deal events. The channel is for the other shapes:
- Escalation-shaped consumption: the payload's subject is the escalation itself (see below), not the deal event, so a receiver files a ticket from one document without consuming the whole event catalogue.
- Per-kind filtering: the channel takes a
kindslist (redlineandbillingonly, say). An endpoint subscribes by event type — every escalation or none. - An auth header: many receivers sit behind a gateway that wants
AuthorizationorX-API-Key. The channel carries one, its value sealed at rest and only ever served masked; endpoints offer none.
Register one with your callback URL, the signing secret your receiver verifies with, and optionally the auth pair. Generate the secret once — whsec_$(openssl rand -base64 32) gives the Standard Webhooks shape — and configure the same material on your receiver:
# operationId: create_escalation_channel
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/escalation-channels \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{"kind":"webhook","name":"Deal desk queue","url":"https://tools.example.com/hooks/skip","credential":"whsec_<your signing secret>","kinds":["question","redline"],"auth_header":"X-API-Key","auth_secret":"<your gateway key>"}'
The url must be absolute https; the credential is the signing secret (whsec_…), sealed at rest, never echoed back (reads serve masked_credential) and rotated by re-pasting. auth_header and auth_secret come as a pair or not at all. Disable a channel and it receives nothing — queued deliveries wait; delete it and the queued history goes with it. test_escalation_channel sends a fixed test delivery through the real transport, and every attempt lands in the channel's own delivery log with its per-attempt history and replay (list_escalation_channel_deliveries, replay_escalation_channel_delivery).
The payload (version 1). Each delivery POSTs one JSON document, signed with the standard headers (webhook-id, webhook-timestamp, webhook-signature) — verify it exactly as you verify any webhook, with the secret you pasted:
{
"version": 1,
"id": "<message id — the webhook-id header, your dedupe key>",
"created_at": "<when the event was emitted, RFC 3339>",
"escalation": { "id": "esc_…", "kind": "question", "subject": "…", "body": "…", "…": "the escalation's API representation, member for member" },
"deal": { "id": "deal_…", "state": "…", "buyer_email": "…", "…": "the deal snapshot from the event" },
"api_url": "https://app.skipthesell.com/api/v1/escalations/esc_…"
}
escalation and deal are exactly the members the deal.escalated event carries, and api_url addresses the escalation record — the source of truth, where your human answers it. A version_resolution escalation adds configuration_version_id at the root. The test delivery renders {"version": 1, "id": …, "created_at": …, "test": true, "api_url": "https://app.skipthesell.com/api/v1/escalation-channels/escch_…"} — api_url there is the channel's API URL (the escch_… resource), not your callback URL. New members are additive within a version; a removal, rename or re-type bumps version. Retries, the ~24-hour backoff window and replay are the webhook contract verbatim, scoped to the channel's own delivery log. One rule differs in kind, not in mechanics: a 410 Gone response is a permanent refusal — the delivery fails and the channel is disabled, so answer 410 only when you want nothing more from this channel (a ticketing receiver must not use it for "already filed"; answer 200 and dedupe on the id).
The system's own escalations#
The promise is not only for buyer questions. Where the system cannot decide for you, it hands you the decision, on the same record.
A pinned version whose grace ran out under human-confirm. The deal's version acceptance policy is yours (core concepts); under human-confirm the expiry clock pauses when the grace period ends and the system opens a version_resolution escalation, created once per pinned version and re-fired to your recipients after seven days unanswered. The buyer is never blocked while you decide. The answer is not a response row on the escalation — it is a decision on the deal, and the system closes the escalation with it:
# operationId: decide_deal_version_honoring
curl -b cookies.txt -X POST https://app.skipthesell.com/api/v1/deals/{deal_id}/version-honoring \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <generate a fresh value>' \
-d '{"decision": "honor"}'
honor behaves as always for this deal — it continues on the pinned version to completion. decline behaves as no — the deal expires, and the buyer restarts into a successor on the current version or escalates. The decline window closes at contract formation: past signing you can no longer decline. Anything else answers 409 state_conflict (the policy is not human-confirm, the decision already landed, the grace has not ended, or the deal is past signing).
Payment facts a human must see. The payment lanes raise their own rows: billing for a payment fact — an order paid that the quote check rejected, for instance — and payment_authentication when a buyer agent's pay reaches the provider's authentication step, which only the buyer's billing contact can complete in a browser. Both carry the system as created_by, both land in the same inbox, and neither is deduplicated away by the other: an authentication demand on a deal with a payment failure still reaches you.
Drift. Reconciliation recomputes billable quantities from our usage-event log — the system of record — and compares them with what your billing integration reports. A mismatch is recorded as a reconciliation row and fires usage-reconciliation.drifted, flagged for a human and never auto-corrected. It is the same promise in a metering record rather than an escalation row: the system does not guess which side is right, it shows you both totals.
What escalation is not#
It is not a way around your own rules. A redline always reaches a human, however small; a gate your policy reserves for a human stays reserved, whoever holds a credential; and escalation never bypasses a gate, it asks a person to decide. The pre-approved alternatives you configure are the buyer's self-serve path, and every one of them that is not taken leads here. For agents covers the same surface from the buyer agent's side, and the agent quickstart escalates a deal step by step.