# Security & data

> Console sign-in and roles, how API keys are stored and why they cannot be recovered, rolling versus revoking, the no-CORS posture, webhook signing, workspace isolation, what the request log keeps, call recording, and how to report a vulnerability.

Source: https://docs.amomic.in/operate/security

---

This page describes what the platform actually does with your credentials and your data: console accounts, API keys, webhooks, isolation between workspaces, the request log and call recordings. It is written to be checked rather than believed. Every claim here corresponds to a behaviour you can observe from the outside.

## Console accounts

- **Sign in** with an email and password, or with Google. An email address must be verified before any organization opens; Google accounts arrive verified. See [Create your account](/start/create-account).
- **Passwords** are at least 8 characters. A reset link is emailed and the page never says whether an address has an account. A new password set from a reset link needs a letter and a number. See [Signing in and resetting your password](/start/create-account#signing-in-and-resetting-your-password).
- **Roles** decide what someone can do: owner, admin or member. Members don't see Channels, Campaigns or Developers. Only the owner changes the plan, pays and adds credits. See [Workspaces & roles](/concepts/workspace#the-three-roles).
- **Removing someone** ends their access to that organization immediately. If it was their only organization, they are signed out. See [Remove someone](/console/team#remove-someone).
- **API keys don't belong to people.** Removing a person leaves every key they created working. Roll or revoke the keys they had access to.
- **Quote your Organization ID,** from [Settings → Organization](/console/settings#organization), when you write to us about your workspace.

## API keys

### The secret is hashed, and nobody can recover it

A key looks like this:

```
amk_live_7Fk2xQ9pR4vN8mJ1cT6yW3zB5hL0dG2s
│   │    └─ 32 random bytes, base64url encoded
│   └────── environment: live or test
└────────── product prefix, always amk
```

The 32 random bytes come from a cryptographically secure generator. What we store is **`SHA-256` of that secret and nothing else**. The raw token exists in exactly one place after creation: wherever you put it.

That means the secret is not recoverable by anyone. Not by support, not by an engineer with database access, not by us under any circumstances. This is a deliberate design decision and not a limitation we intend to fix — a system that can show you your key can be compelled, tricked, or breached into showing it to someone else. A hash cannot.

SHA-256 rather than a slow password hash is also deliberate. Slow hashes exist to defend low-entropy human-chosen passwords against offline guessing. These secrets carry 256 bits of entropy from a random generator; guessing is not the threat model, and a fast one-way function is the right tool.

Alongside the hash we keep a **key id** — a public, loggable identifier derived one-way from the secret. It is what appears in the request log and in support conversations. It cannot be turned back into a key.

### One reveal, then a masked hint

The full key is shown exactly once, in the **Copy your API key** dialog when you create it. Rolling shows the *new* key once in the same way. After that, the console shows only a masked prefix:

```
amk_live_7Fk2xQ…
```

Enough to tell two keys apart in a list. Not enough to authenticate.

::: danger Copy it before you close the dialog
If you lose a key, there is no recovery path, only a roll, which issues a new one. Put it into your secret manager while the dialog is open. The same applies to a webhook signing secret (`whsec_…`), which is also shown once. If you lose one, add a new endpoint, move your server to its secret, then delete the old endpoint.
:::

The `amk` prefix is there so automated secret scanners can recognise a leaked key in a repository, a paste, or a log. If you receive an alert from one, treat it as real and revoke.

### Roll with grace, or revoke immediately

Two operations with two different purposes. Choose by intent, not by convenience.

**Roll** creates a new key with the **same name, same scopes, and same rate limit**, shows the new key once, and keeps the old key working for **24 hours**. That window exists so you can deploy the new secret to every consumer without a gap in service. When the window ends, the old key returns `401` with `details.reason` of `expired_api_key` — a distinct reason, so you can tell "you missed the deployment window" apart from "this key was never valid".

**Revoke** is immediate and permanent. The key returns `401` with `revoked_api_key` from then on, and there is no un-revoke. The revoked key stays in the list, with no actions, so you can see what existed. Replacing it means creating a new one.

| Situation | Operation |
|---|---|
| Planned rotation, quarterly hygiene | Roll |
| A developer with the key has left, no evidence of misuse | Roll, then revoke the old one early |
| A key appeared in a repository, a log, or a screenshot | **Revoke.** Then create a new one |
| You lost the secret and cannot deploy | Roll |

A revoked or rolled-out key stops working **within seconds**. Verified key records are cached in memory for up to five seconds, so a revocation propagates in that time across every instance serving your traffic; the instance that handled the change applies it immediately.

::: note `last_used_at` is coarse on purpose
It is written at most once a minute per key, to keep verification a single fast read. Use it to spot a key nobody has touched in months, not to reason about a specific request. For that, use the request log.
:::

### Keys are workspace-wide

There is no per-user, per-agent, or per-deployment key. A key belongs to the workspace, and **scopes are the only partitioning mechanism**. There are eleven scopes; **Create key** starts with the two read-only ones, **Read conversations** and **Read agents**, ticked. A key's scopes can't be changed after it is created, so choose them carefully: to change them, create a new key and revoke the old one.

Size keys accordingly: one per system that calls the API, each with the least scope that system needs. That gives you separate rate-limit budgets, separate lines in the request log, and the ability to revoke one integration without taking the rest down. See [Scopes](/api/scopes).

## No CORS on `/api/v1`, deliberately

The public API sends **no** `Access-Control-Allow-Origin` header and does not answer preflight requests. A browser cannot call it from a web page, and no configuration option will change that.

The reasoning is short: an API key in front-end JavaScript is a leaked API key. Anyone can open developer tools and read it out of your bundle, and once it is out you cannot un-leak it — you can only revoke and redeploy. CORS support would be a feature whose main use is making that mistake possible, so it does not exist.

Call `/api/v1` from your backend. For browser use, choose the [hosted widget](/channels/web-chat) or [custom-UI client](/channels/web-chat-custom-ui). Both use short-lived, origin-bound visitor sessions, carry no API key, and are scoped to exactly what a visitor may do.

## Webhook signing

Every delivery to your endpoint carries:

```
Amomic-Signature: t=1754049600,v1=6f1c9e0b4a2d8f37c5e1b0a94d7f2e63c8b1a05e9f3d7c26b4a8e1f0d5c937b2
```

`v1` is `HMAC-SHA256` over the exact string `"{t}.{body}"`, keyed on your endpoint's signing secret. Your verifier recomputes it against the **raw request bytes**, compares in constant time, and rejects any delivery whose timestamp is more than **5 minutes** from now. [Verifying signatures](/webhooks/signatures) has working code.

### Why signing rather than an IP allowlist

We could publish a list of source addresses and ask you to allow them. We sign instead, for four reasons that all bite in production:

- **An allowlist proves origin, not integrity.** It tells you a packet came from our range. It does not tell you the body was not modified in transit, and it cannot tell you the request was not replayed. A signature over the body proves both.
- **Addresses change.** Ours run on managed infrastructure that scales and moves. A published list is a promise to never change infrastructure, or an outage the day we do.
- **Shared infrastructure makes an allowlist weak.** Anything else running on the same platform can appear to be in the range. "It came from a big cloud provider" is close to no assertion at all.
- **A signature works everywhere.** Behind a load balancer, a CDN, a corporate proxy, a tunnel to a laptop. Anywhere the header survives, the check works — which means the check you wrote in development is the check running in production.

The timestamp in the header is what makes replay expensive. A captured delivery re-sent an hour later fails the tolerance check even though its signature is genuine.

::: warning Verify against the bytes you received
The most common webhook bug in any integration: parse the JSON, re-serialise it, verify against the result. It will not match. The body is serialised compactly and every byte is part of what was signed. Read the raw body, verify, and parse only afterwards.
:::

## Transport

Everything is HTTPS. There is no plaintext endpoint for the API and no plaintext option for webhooks — a webhook URL must start with `https://`, and an `http://` URL is rejected at creation with a `400` and the message `Webhook URLs must use https.`

Webhook deliveries do **not follow redirects**. A `301` to a new address is treated as a failed delivery rather than quietly followed to a destination we can't verify. An endpoint's URL can't be edited, so if your endpoint moves, add a new endpoint at the new address, move your server to its signing secret, then delete the old one. Deliveries time out at 10 seconds in total, 5 seconds to connect.

## Workspace isolation

Your data does not cross a workspace boundary. The rule is enforced at the point every query is built, not by a filter someone remembered to add at the end:

- **Every read is scoped to the workspace on the key.** There is no parameter, header, or trick that widens it.
- **A foreign id is a `404`, identical to a missing one.** `GET /conversations/{id}`, `GET /calls/{id}`, and `GET /tickets/{id}` all return the same `not found` message whether the id does not exist or belongs to someone else. That is a deliberate choice: distinguishing the two would confirm the existence of another workspace's data to anyone with an id-shaped guess.
- **Responses are projections, not dumps.** Conversation and message responses carry a fixed set of public fields; internal routing and identity fields are not projected at all.
- **Webhook payloads are allow-lists.** Each event type emits a named set of fields. Adding a field to an internal event does not leak it into your webhook — it has to be added to the projection deliberately.

Credentials you give us — WhatsApp tokens, knowledge base source headers, webhook signing secrets — are held in a managed secret vault, referenced rather than stored, and **masked on every read**. A configuration you write is accepted on the way in and never returned on the way out.

## What the request log keeps

Every `/api/v1` call made with a valid key is recorded in your workspace's own request log:

| Recorded | Not recorded |
|---|---|
| Method and path | Request bodies |
| HTTP status and `error_code` | Response bodies |
| Duration | Any secret, header value, or token |
| Key id and key name | |
| `request_id` and `correlation_id` | |

**Developers → Logs** shows the newest 50 of them: time, method and path, key name, status with its error code, and duration. It doesn't show the request id or the correlation id, and it has no filter, so keep `request_id` from error responses in your own logs. See [Logs](/console/developers#logs).

Retention is **7 days**, enforced when the log is read as well as when records expire — so the seven-day promise is true at the moment it is made, not whenever a cleanup last ran.

Requests refused before the key is recognised (a missing, malformed, wrong or revoked key, a suspended workspace, or the rate limit) are not attributed to any workspace and don't appear in anyone's log.

Webhook delivery records are kept for the same 7 days, one per attempt. Each carries the endpoint, event type, event id, attempt number, status code, the start of any error text, duration, and the full event as we sent it, which is what **Replay** resends. They are a record of what we sent you, not of what you did with it.

Send `X-Correlation-Id` on your requests and it is echoed back and recorded with the request, which lets support join your traces to ours during an investigation.

## Call recording

Recording is **off by default** on every phone number. It is turned on per number with **Record calls**, and nothing is captured until someone does.

When it is on, callers are told in one of two ways, chosen per number under **Recording notice**:

- **Agent says it in the greeting** (the default): the agent works a short notice into its first line, in the call's opening language.
- **Automated announcement first**: a fixed message plays before the agent greets: *"This call may be recorded for quality and training purposes."*

It is a notice, not a consent step. There is no key to press to decline, so if your use needs a genuine opt-out, build it into what your agent says and does. See [Recording and the recording notice](/channels/phone-workspace#recording-and-the-recording-notice).

A recording is one audio file per call, with the caller and the agent on separate channels. It is stored in your workspace's own space with no public access. Playback links are made when someone opens the call in the console and **expire after an hour**, so a link that leaks stops working. There is no automatic deletion schedule for recordings; take that into account in your own retention policy.

::: note Recording is a decision you own
Turning it on changes what you collect about your customers. Whether that is appropriate, and what you must tell people beyond the notice, depends on your jurisdiction and your own policies. The platform gives you the switch and the notice; the rest is yours.
:::

## Reporting a security issue

If you have found a vulnerability in the API, the console, the widget, or anything else we operate, we want to hear about it.

**Write to `support@amomic.in`.** Include what you found, the steps to reproduce it, and what an attacker could do with it. A request id, a timestamp with its time zone, and your Organization ID all help us find it quickly.

Two requests, and we mean both of them:

- **Tell us before you publish.** Give us a reasonable window to fix the issue and get a fix deployed before the details are public. We will keep you informed while we work on it, and we are happy to credit you when it is resolved.
- **Do not test against other customers' workspaces.** Use your own. Do not access, modify, or retain data that is not yours; do not run traffic that degrades service for anyone else; and if you stumble into someone else's data while investigating, stop, and tell us what you saw rather than exploring further.

If you find a leaked key — yours or one you believe is someone else's — revoke it first if you can, then tell us.

For a security review or a procurement questionnaire, the same address is the right one. We would rather answer a specific question accurately than point you at a badge.

## Related

::: cards

::: card Authentication
[Key formats, header forms, and all six 401 reasons](/api/authentication).
:::

::: card Scopes
[The eleven accepted scopes](/api/scopes) and how to size a key to a system.
:::

::: card Verifying signatures
[Working verifiers](/webhooks/signatures) in Node.js and Python, and the mistakes that break them.
:::

::: card Going live
[The pre-launch checklist](/operate/going-live) — where most of this becomes a task with a tick box.
:::

:::
