# icall.tel Video API — integration guide

Everything you need to create a video call from your own code, hand each person
a join link, and be told when things happen. You should not need to read our
source to use this.

Machine-readable: **`GET /api/v1/openapi.json`** (no key required — you should
not need working auth to find out how auth works).

---

## What this API is not

**It does not deliver invitations.** We return a room and a join URL. Sending
that link — over SMS, WhatsApp, email or anything else — is your job.

There is no send endpoint here, and looking for one is a reasonable thing to do,
so: this is how every video API in the industry works. Twilio Video, Daily,
LiveKit and Zoom all return a join URL and leave delivery to the caller. The
separation is deliberate — messaging has its own compliance, sender identities,
opt-outs and delivery receipts, and none of that belongs in a video product.

In this ecosystem, **a2p-network** is the messaging provider. The usual shape is:

```
POST /api/v1/meetings              (icall.tel)  → meeting + joinUrl
POST /api/v1/meetings/{id}/join-token (icall.tel) → a link for one person
POST <a2p-network>/api/v1/messages              → deliver that link
```

---

## 1. Get a key

Sign in and go to **`/api-keys`**.

Choose a name, tick the scopes you need, set a rate limit, create it. **The key
is shown once.** We store only a SHA-256 hash of it — if you lose it, revoke it
and make another; there is no way for us to show it to you again.

Send it on every request:

```http
X-API-Key: ick_xxxxxxxxxxxxxxxxxxxxxxxx
```

`Authorization: Bearer ick_…` works too, if that suits your HTTP client better.

---

## 2. What scopes mean

Scopes are values from the product's own permission matrix. There is no separate
API vocabulary to learn, and no mapping table to keep in step.

| Scope | Lets a key |
|---|---|
| `meeting:read` | read and list meetings, list participants, **mint join tokens** |
| `meeting:create` | create meetings |
| `meeting:update` | change a meeting |
| `meeting:delete` | cancel a meeting |
| `meeting:end` | end a call that is in progress |
| `recording:read` | list and read recordings |
| `recording:download` | fetch the actual media |
| `recording:delete` | delete a recording |
| `room:*`, `member:*`, `organization:read`, `organization:update`, `billing:read` | as named |

`organization:read` and `organization:update` are what govern webhook
subscriptions — rerouting where every event in your organization is delivered is
configuration of the organization, not of any one meeting.

### Two rules worth knowing before you design around them

**A key can never do more than the account that created it.** Checked when the
key is minted, and again on every request. If your role changes, your keys
change with it — you do not have to go and edit them, and you cannot work around
a demotion by keeping an old key.

**`recording:read` and `recording:download` are different.** Watching a
recording and taking a copy of it are different risks. A key that only needs to
show recordings in your UI should not hold `recording:download`.

**Not available as scopes at all:** `settings:read`, `settings:update`,
`organization:delete`, `billing:manage`. Administering the deployment, ending a
tenant and moving money are not things a bearer token should do.

---

## 3. Create a room and get a join link

```bash
curl -X POST https://your-instance/api/v1/meetings \
  -H "X-API-Key: $ICALL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Design review"}'
```

```json
{
  "meeting": {
    "id": "b3f1…",
    "slug": "k4m2xq",
    "title": "Design review",
    "status": "live",
    "joinUrl": "https://your-instance/k4m2xq",
    "organizationId": "org_…"
  }
}
```

For a scheduled call, pass `"type": "scheduled"` and `scheduledStartAt` as an
ISO 8601 timestamp.

`joinUrl` is the shareable link — anyone with it can join. When you want each
person admitted under their own name, mint a token instead.

---

## 4. Mint a join token

```bash
curl -X POST https://your-instance/api/v1/meetings/$MEETING_ID/join-token \
  -H "X-API-Key: $ICALL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"displayName": "Dana Okoro", "participantId": "crm_8812", "ttlSeconds": 900}'
```

```json
{
  "joinToken": "jt1.YjNmMQ.cDg4MTI.RGFuYSBPa29ybw.1789459200.Xb7…",
  "expiresAt": "2026-09-16T12:40:00.000Z",
  "participantId": "crm_8812",
  "joinUrlWithToken": "https://your-instance/k4m2xq?t=jt1.…"
}
```

- `displayName` is **signed into the token**, so it cannot be edited afterwards.
  The name is what other participants see and trust; a token whose name could be
  changed without breaking the signature would be an impersonation primitive.
- `participantId` is yours. Pass your own identifier — a CRM contact id — and a
  delivery receipt from a2p-network and a participant record here line up
  without a join table. One is generated if you omit it.
- `ttlSeconds` defaults to 900 (15 minutes), maximum 3600.

This is **our** token, not the video provider's. Which provider carries the
media is chosen by the operator at runtime and can change without a redeploy;
nothing in this API names it, so nothing you write against this API breaks when
it does.

Then hand `joinUrlWithToken` to a2p-network, or to your own mailer.

---

## 5. Register a webhook

```bash
curl -X POST https://your-instance/api/v1/webhooks \
  -H "X-API-Key: $ICALL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://yours.example.com/hooks/icall",
        "events": ["call.ended", "recording.ready"]
      }'
```

```json
{
  "subscription": { "id": "…", "hasSecret": true },
  "secret": "whsec_9f2c…",
  "secretNotice": "Store this now. It is shown once…"
}
```

**Store the secret immediately.** Later reads say `hasSecret: true` and nothing
more. If you lose it, `POST /api/v1/webhooks/{id}/rotate-secret`.

### Events

`call.started` · `call.ended` · `participant.joined` · `participant.left` ·
`recording.ready` · `recording.failed`

Wildcards work the same way they do in a2p-network: `call.*`,
`participant.*`, `recording.*`, or `*` for everything including events added
later.

### The envelope

Every event has the same shape, so you can route on `event` before you know
anything about the payload:

```json
{
  "id": "evt_9c1f…",
  "event": "recording.ready",
  "createdAt": "2026-09-16T12:31:07.412Z",
  "organizationId": "org_…",
  "data": { "recording": { "id": "rec_…", "meetingId": "b3f1…", "…": "…" } }
}
```

`data` is a **subset** of what the REST endpoints return, on purpose: an event
says what happened, a `GET` gives you the current state. The `id` is always
there so you can fetch that state when you need it rather than caching a payload
that goes stale the moment the call moves on.

### Delivery, stated plainly

**One attempt per event. There is no retry.** Every attempt — success or failure
— is in `GET /api/v1/webhooks/{id}/deliveries`.

That matters for how you build your endpoint: **acknowledge fast and process
asynchronously.** Return 2xx as soon as you have the bytes, then do the work. An
endpoint that does ten seconds of work before responding will time out and you
will lose the event.

We do not follow redirects. A 3xx is recorded as a failed delivery — point the
subscription at the final URL.

---

## 6. Verify the signature

Every delivery carries:

```http
X-ICALL-Event:     recording.ready
X-ICALL-Signature: t=1789459200,v1=5e8f2a…
Content-Type:      application/json
User-Agent:        iCall-Tel-Webhook/1.0
```

**The signed string is:**

```
{timestamp}.{rawBody}
```

A literal `.` between the Unix-seconds timestamp and **the exact bytes of the
request body**, before any parsing. The timestamp is *inside* the HMAC, not just
beside it — otherwise an attacker who captured a delivery could edit the
timestamp header freely and replay it for ever.

Then: `HMAC-SHA256(secret, thatString)`, hex.

Tolerance is **300 seconds** either way. Reject anything outside it, in both
directions — a timestamp far in the *future* would otherwise let a captured
delivery be post-dated and replayed.

### This is byte-for-byte a2p-network's scheme

Same canonical string, same `t=…,v1=…` layout, same 300-second tolerance, same
`v1` version marker. **Only the header name differs** — `X-ICALL-Signature`
instead of `X-A2P-Signature`. Write one verifier, pass the header name in, use
it for both providers.

### Node.js

```js
import crypto from "node:crypto";
import express from "express";

const app = express();

// The raw bytes. This is the part people get wrong: express.json() parses and
// discards the original body, and re-serialising it will not reproduce the
// bytes we signed — key order and whitespace can differ. Keep the raw buffer.
app.post(
  "/hooks/icall",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const header = req.get("X-ICALL-Signature");

    if (!verify(process.env.ICALL_WEBHOOK_SECRET, rawBody, header)) {
      return res.status(400).send("bad signature");
    }

    // Acknowledge FIRST, work afterwards. One attempt, no retry.
    res.status(200).end();
    handle(JSON.parse(rawBody));
  }
);

const TOLERANCE_SECONDS = 300;

function verify(secret, rawBody, header) {
  if (!header) return false;

  let timestamp = null;
  const signatures = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (!value) continue;
    if (key === "t") {
      if (!/^\d+$/.test(value)) return false;
      timestamp = Number(value);
    } else {
      // Unknown versions are IGNORED, not rejected, so a future v2= can be
      // added alongside v1= without breaking this code.
      signatures[key] = value;
    }
  }
  if (timestamp === null || !signatures.v1) return false;

  // Window BEFORE the HMAC: a replay is rejected without burning a comparison,
  // and the reason you see is the true one rather than "mismatch".
  const age = Math.floor(Date.now() / 1000) - timestamp;
  if (age > TOLERANCE_SECONDS || age < -TOLERANCE_SECONDS) return false;

  // THE CANONICAL STRING.
  const canonical = `${timestamp}.${rawBody}`;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(canonical)
    .digest("hex");

  const a = Buffer.from(signatures.v1);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Python

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 300

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    if not header:
        return False

    timestamp, signatures = None, {}
    for part in header.split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if not value:
            continue
        if key == "t":
            if not value.isdigit():
                return False
            timestamp = int(value)
        else:
            signatures[key] = value

    if timestamp is None or "v1" not in signatures:
        return False

    age = int(time.time()) - timestamp
    if age > TOLERANCE_SECONDS or age < -TOLERANCE_SECONDS:
        return False

    # THE CANONICAL STRING: timestamp, a literal dot, then the raw bytes.
    canonical = f"{timestamp}.".encode() + raw_body

    expected = hmac.new(secret.encode(), canonical, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signatures["v1"], expected)
```

### When a signature will not verify

Almost always one of these, in this order:

1. **You verified a re-serialised body.** `JSON.parse` then `JSON.stringify` is
   not the same bytes. Use the raw body.
2. **Your clock is off by more than five minutes.**
3. **You are using a rotated secret.** Rotation takes effect immediately.

`GET /api/v1/webhooks/{id}/deliveries` returns `requestBody` — the exact bytes
we signed. Compare it with what your endpoint received; that tells you which of
the three it is without guessing.

---

## 7. Recordings

```
GET    /api/v1/recordings?meetingId=…      recording:read
GET    /api/v1/recordings/{id}             recording:read
GET    /api/v1/recordings/{id}/download    recording:download
DELETE /api/v1/recordings/{id}             recording:delete
```

`download` supports byte ranges, so a dropped connection does not mean starting
a two-hour file again.

`DELETE` is a **soft delete**: the row is archived and the media is removed. The
response says `"mode": "archived"` so that if you are auditing retention you
know exactly what it bought you.

---

## 8. Organizations

If your key's account belongs to **one** organization, ignore this section.

If it belongs to several, every endpoint accepts `organizationId` — as a body
field on writes, a query parameter on reads. Omit it and we will use the one
organization you have; omit it when you have several and you get:

```json
{
  "error": {
    "code": "organization_ambiguous",
    "message": "This key's account belongs to more than one organization…",
    "organizations": ["org_a", "org_b"]
  }
}
```

**We refuse rather than guess.** Picking one would file a call under the wrong
customer, and nothing about that failure announces itself — the meeting works,
the recording works, and the wrong tenant can see both.

---

## 9. Rate limits

Per key, per minute, set when you create the key (default 120). Over it:

```json
{ "error": { "code": "rate_limited", "message": "…", "retryAfterSeconds": 41 } }
```

Honestly: the counter is **per process**. Behind more than one instance the
effective limit is per instance. It is a fairness mechanism so one integration
cannot starve another — not a security control, and you should not design around
it as one.

---

## 10. Error codes

Branch on `code`. It is a closed set. `message` is for a human and is not a
stable interface.

| Code | HTTP | Means |
|---|---|---|
| `unauthenticated` | 401 | key missing, wrong or revoked |
| `no_organization` | 403 | key valid, but no organization to act in |
| `organization_ambiguous` | 400 | several organizations, none named |
| `scope_forbidden` | 403 | key lacks the scope, or its owner lacks the permission |
| `invalid_request` | 400 | malformed body or parameter |
| `not_found` | 404 | no such resource — **or not yours** |
| `rate_limited` | 429 | over the key's limit |
| `provider_error` | 502 | the video provider failed |
| `internal` | 500 | our fault |

### Two of these are deliberate and worth explaining

**`not_found` covers "not yours".** Asking for another organization's meeting
gets a 404, identical to asking for an id that never existed. If the two
differed you could discover which ids are real by watching which error came
back. It costs you a little clarity when you have a genuine bug; it costs an
attacker the ability to enumerate.

**`unauthenticated` and `no_organization` are separate** — the same split
a2p-network makes. Collapsing them into 401 would send you off to regenerate a
key that was never the problem.

### Alignment with a2p-network

Identical code and status: `unauthenticated`, `no_organization`,
`invalid_request`, `not_found`, `rate_limited`, `internal`.

Renamed for this domain, same position and status: a2p's `channel_forbidden` is
`scope_forbidden` here; a2p's `channel_error` is `provider_error`. Those two are
named after a2p's resource — a channel — and reading `channel_forbidden` from a
video API would reasonably make you wonder which channel.

`organization_ambiguous` has no a2p counterpart.

---

## 11. Which video provider is in use

You cannot tell, and that is the design.

The operator chooses between the supported providers at runtime, without a
redeploy. Nothing in any response names one — not a call id, not a recording
URL, not a token. Every URL we hand you is ours and stays stable across a
provider change.

If you find a field that reveals it, that is a bug — please report it.
