# Escendo agent API

For an AI agent (Claude Code, Cursor, Codex, anything that can run `curl`) setting up Escendo for a person, end to end. Escendo identifies the companies, and for United States visitors the people, visiting a website, and sends them to Slack, email or a CRM.

The person does one thing: enter a card on a Stripe Checkout page you give them. Nothing is charged for 7 days (or 500 identified people, whichever comes first), and they can cancel before then.

Follow the steps in order. Every error is JSON `{"error": "<code>", "message": "<sentence>"}`: branch on `error`, and when this guide says to tell the person something, repeating `message` is fine.

```
BASE=https://app.escendo.co        # the API
CONNECT=https://connect.escendo.co # the tag and install checks
```

## Before you start

Ask the person for:

1. **Their work email.** The account is created under it, and the dashboard sign-in link goes there.
2. **The website's domain**, e.g. `acme.com`. Use the domain only: no `https://`, no path. `www.` is ignored.
3. Optionally: a name for the site, and what kind of business it is (`saas`, `law`, `clinic`, `agency`, `home_services`, `other`).

**If they already have an Escendo account** (they have signed in to app.escendo.co before): do not sign up. Ask them to open https://app.escendo.co/settings#agent, click **Create token** under *Agent token*, and give you the token (`esc_agent_` + 64 hex characters). Skip to step 2 with it. Sign-up will refuse their email anyway (409 `account_exists`).

## 1. Sign up

Always send an `Idempotency-Key` header (optional, but without it a lost response cannot be recovered). It is 16–128 characters of letters, digits, `-` or `_`; a UUID from `uuidgen` works. Keep it with the request. If the call fails on the network or times out, send the exact same request with the same key: you get the same answer, including the same token, and nothing is created twice. Without the key, a repeat of a sign-up gets 409 `signup_pending` and no token.

```bash
IDEM=$(uuidgen)
RESPONSE=$(curl -sS -X POST "$BASE/api/agent/signup" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d '{"email":"jane@acme.com","domain":"acme.com","name":"Acme","vertical":"saas"}')
```

Save the two values you need for the rest of the guide:

```bash
SITE_KEY=$(echo "$RESPONSE" | jq -r .site_key)
AGENT_TOKEN=$(echo "$RESPONSE" | jq -r .agent_token)
```

Body fields: `email` and `domain` are required. `name`, `vertical`, `plan` and `source` are optional. Send `source` (letters, digits and `_ . / : -`, up to 100 characters) when the person's link carried a campaign, e.g. `"source": "reddit/r2"` for `?utm_source=reddit&utm_campaign=r2`, or the bare value of `?ref=`. `plan` is `pro_500` (default, 500 people a month, $147), `pro_2k` ($398) or `pro_5k` ($819); every plan starts with the free trial.

**201 Created** (200 when it is a retry of the same request):

```json
{
  "site_key": "esc_0123456789abcdef0123456789abcdef",
  "agent_token": "esc_agent_…64 hex…",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_…",
  "status_url": "https://app.escendo.co/api/agent/status",
  "tag": "<script async src=\"https://connect.escendo.co/s.js?k=esc_…\"></script>",
  "verify_url": "https://connect.escendo.co/verify/esc_…",
  "message": "…"
}
```

- `agent_token` is shown **once**. Keep it for the rest of this session. Do not write it into the website's code or commit it anywhere. Send it only in the `Authorization: Bearer` header.
- `site_key` is public by design; it goes in the page HTML.

Errors:

| Status | `error` | What to do |
|---|---|---|
| 400 | `missing_fields`, `invalid_email`, `invalid_domain`, `invalid_vertical`, `invalid_plan`, `invalid_idempotency_key` | Fix the input (ask the person if it came from them) and send again with a **new** idempotency key. |
| 409 | `account_exists` | The email already has an account. Tell the person to sign in at https://app.escendo.co, go to Settings → Agent token, create one and give it to you. Continue at step 2 with it. |
| 409 | `signup_pending` | A sign-up for this email is already waiting for payment and you do not hold its key. If you made it earlier in this session, retry with the original idempotency key and body. Otherwise tell the person: "Someone started an Escendo sign-up for this email in the last day. If it was you, finish it from that link; if not, it expires on its own." It expires after `retry_after_seconds`. |
| 409 | `domain_claimed` | Another Escendo account has proven it owns this domain. Tell the person to email hello@escendo.co. |
| 422 | `idempotency_mismatch` | You reused a key with a different body. Use a new key. |
| 429 | `rate_limited` | Too many sign-ups from this network. Wait an hour. |
| 502 | `checkout_failed` | The account exists but Stripe did not answer. Wait a minute and resend the exact same request with the same key. |
| 503 | `billing_unavailable` | Escendo cannot take sign-ups right now. Try later. |

## 2. Install the tag

Add the `tag` from step 1 as the last element inside `<body>` of every page, once, in the shared layout. Follow https://escendo.co/install.md for where that is in each framework and how to update a Content-Security-Policy. Then deploy the site (or ask the person to) and load any page once in a browser.

Check it arrived (no auth needed):

```bash
curl -sS "$CONNECT/v/$SITE_KEY"
# {"found":true,"receivingBeacons":true,"lastBeaconAt":"…","domains":[{"domain":"acme.com","verified":false}]}
```

`receivingBeacons` (camelCase here; `/api/agent/status` reports the same thing as `install.receiving_beacons`) becomes `true` within seconds of a page view on the live site. `{"found":false}` (404) means the site key is wrong: use the one from step 1.

Then prove the domain is theirs. Escendo looks for the site key in the homepage HTML (or a DNS TXT record `escendo-verify=<site_key>` if the tag is injected by a tag manager):

```bash
curl -sS -X POST "$CONNECT/verify/$SITE_KEY"
```

```json
{"found":true,"domains":[{"domain":"acme.com","verified":false,"error":"not_found"}]}
```

Each entry in `domains` turns `"verified": true` once proven. People are only identified on verified domains. If it stays `false`:

- `"error":"not_found"`: the key is not in the homepage HTML yet. Check the deploy is live (view the page source), then call `/verify` again.
- `"error":"claimed"`: another Escendo account proved this domain first. Tell the person to email hello@escendo.co.
- 429 `rate_limited`: wait a minute before calling again.
- No answer at all (connection refused or timeout): Escendo's install service is unreachable. Carry on with the other steps and retry later; `/api/agent/status` reports the same install state.

If you cannot deploy the site yourself, give the person the tag and where it goes, and tell them you will check it once it is live.

## 3. Hand the person the checkout link

Give them `checkout_url` and say something like:

> Your Escendo account is set up. To turn it on, enter a card here: <checkout_url>. New accounts are free for 7 days (or 500 identified people), and you can cancel before then from the dashboard.

You can do this before or after step 2. The link works for 23 hours; an unpaid sign-up is deleted after 24 hours, after which you can sign up again.

After paying, the person sees "One last step" and is emailed a confirmation link. **Until they open it and confirm, nothing you configure in steps 5 and 6 (Slack, CRMs, webhooks) receives a lead.** The link signs them in and shows them exactly where you pointed their leads, with "Yes, that was me" and "Not me, reject". Rejecting revokes your token and removes those destinations.

Tell the person this plainly, for example:

> One more thing: open the email from Escendo and confirm the setup, so leads start reaching Slack.

If they did not get the email, `POST /api/agent/resend-confirmation` (Bearer token, no body) sends it again: **200** `{"ok": true, "sent_to": "…"}`, **409** `already_confirmed` (nothing to do) or `not_paid` (they have not entered a card yet).

Do not tell them to sign in at app.escendo.co before confirming: signing in any other way rejects the setup you made, and they would have to configure it again in the dashboard.

## 4. Poll status

```bash
curl -sS "$BASE/api/agent/status" -H "Authorization: Bearer $AGENT_TOKEN"
```

```json
{
  "plan": "trialing",
  "plan_id": "pro_500",
  "current_period_end": "2026-10-23T18:00:00.000Z",
  "checkout_url": null,
  "pending_expires_at": null,
  "install": {
    "site_key": "esc_…",
    "tag": "<script …></script>",
    "receiving_beacons": true,
    "last_beacon_at": "2026-10-16T18:01:02.000Z",
    "domains": [{ "domain": "acme.com", "verified": true }]
  },
  "destinations": { "slack": false, "alert_email": null, "webhooks": [], "crm": [], "paused": [] },
  "owner_email": "jane@acme.com",
  "vertical": "saas",
  "hot_pages": [],
  "ready": false,
  "next": ["Choose where leads go: POST /api/agent/config (Slack or email) or /api/agent/crm."]
}
```

- `plan`: `none` (not paid yet; `checkout_url` is set), `trialing` (free trial; `current_period_end` is when it ends), `active` (paying; this is also what you see straight away if that card already had a free trial on another Escendo account, since there is one trial per card), `past_due` (card failed; the person should update it in the dashboard under Billing) or `canceled`.
- **Paid means `trialing` or `active`.** Poll every 10–15 seconds while `plan` is `none`, and stop as soon as it is either. Status asks Stripe directly, so it changes as soon as the card goes through.
- `hot_pages: []` means the defaults for the vertical are in use.
- `next` lists what is left, in plain words. `ready` is `true` when the plan is paid, the tag is receiving, a domain is verified and at least one destination is set.
- 401 `invalid_token`: the token is wrong or was revoked. Ask the person for a new one from Settings → Agent token.

## 5. Choose where leads go

Send only the fields you want to change. All are optional:

```bash
curl -sS -X POST "$BASE/api/agent/config" \
  -H "Authorization: Bearer $AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "slack_webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX",
    "alert_email": "jane@acme.com",
    "hot_pages": ["/pricing", "/demo"],
    "vertical": "saas",
    "add_domains": ["acme.io"]
  }'
```

- `slack_webhook_url`: a Slack incoming webhook. Ask the person to create one at https://api.slack.com/messaging/webhooks (name the app Escendo so alerts show under that name, pick the channel, copy the URL) and paste it to you. `null` turns Slack off.
- `alert_email`: where email alerts go. The account's own email always works; an address at the site's verified domain works too. Only accepted once `plan` is `trialing` or `active`. `null` turns it off.
- `hot_pages`: paths that signal buying intent (each starts with `/`; `/pricing` also covers `/pricing/team`). A visit to one is a strong reason for Escendo to identify the visitor, and company-only alerts are sent only for them. A list you send replaces the defaults entirely; leave it out to use the defaults for the vertical.
- `add_domains`: other domains the same tag runs on (each must be verified like the first).

**200** `{"ok": true, "changed": ["slack_webhook_url", …]}`

| Status | `error` | What to do |
|---|---|---|
| 400 | `invalid_slack_webhook` | Not a Slack webhook URL. Tell the person; it must start with `https://hooks.slack.com/services/`. |
| 400 | `alert_email_not_allowed` | Use the account's email, or an address at the verified domain. |
| 409 | `plan_required` | Email alerts wait for the card. Poll status until `plan` is `trialing`, then retry. |
| 400 | `invalid_hot_pages`, `invalid_vertical`, `invalid_domains`, `unknown_fields`, `empty` | Fix the request. |
| 409 | `domain_claimed` | That extra domain is proven by another account. |

## 6. Connect a CRM (optional)

```bash
curl -sS -X POST "$BASE/api/agent/crm" \
  -H "Authorization: Bearer $AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{"provider":"hubspot","key":"pat-na1-…"}'
```

`provider` is one of the ids below; `key` is the token the person creates in that CRM; some providers also need `extra` (an account domain, URL or database id). Send an unknown provider to get the full list with the fields each needs:

```bash
curl -sS -X POST "$BASE/api/agent/crm" -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" -d '{"provider":"?"}'
```

The key is tested against the CRM before it is saved.

**200** `{"ok": true, "provider": "hubspot", "account": "Acme Inc"}`

| Status | `error` | What to do |
|---|---|---|
| 400 | `unknown_provider` | Pick one from `providers` in the response. |
| 400 | `missing_key`, `invalid_extra` | Ask the person for the missing piece; `message` names it. |
| 400 | `key_refused` | The CRM rejected the key. Ask the person to check it (and its permissions) and send it again. |

## 7. Finish

Poll status once more. `ready` needs the card, the tag, a verified domain, a destination, and `owner_confirmed: true` (the person clicked the confirmation link). When `ready` is `true`, tell the person:

> Escendo is live on acme.com. New visitors from companies (and, in the US, the people) who read your hot pages will be sent to <Slack / email / CRM>. You can see everyone at https://app.escendo.co.

When `ready` is still `false`, tell them what is done and turn each item in `next` (and any error you could not resolve, such as a refused CRM key) into one plain request, for example:

> Your card is in and leads will go to Slack. Two things left: add this tag to your site's shared layout and deploy it — <tag> — and send me a HubSpot private app token with contact read and write access, since the one you gave was refused. And open the confirmation email from Escendo, so leads start flowing.

## Things to know

- One account covers one site.
- The agent token can read install status and change the settings above. It cannot read leads (that is the separate *AI access* token in Settings) and cannot touch billing. The person can revoke it any time in Settings → Agent token.
- Never put the agent token in a URL, a file in the repository, or the website.
