# Audit log (/docs/audit-log) Your organization has one audit log. It records changes, not reads: | Actor (`actor_type`) | Changes | | -------------------- | ---------------------------------------------------------------------------------------- | | `user` | Made by a member in the dashboard: API keys, domains, members, invitations, settings… | | `api_key` | Made with an API key: domains, webhook endpoints, suppressions, canceled emails… | | `system` | Made by COSS support, shown as `COSS support` (for example suspending the organization) | Each event has an `action` (`.`, e.g. `api_key.created`, `domain.deleted`, `member.role_changed`), the target it changed, and the project and environment it happened in (`null` for organization-wide changes such as domains and members). Changes made through the API carry the request's `request_id` in their `metadata`, so you can find the request in the environment's [logs](/docs/logs). Owners and admins can open **Settings → Audit log** in the dashboard to filter events and export them as CSV. Or read them through the API: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.auditEvents.list({ action: ["domain.", "api_key.created"], from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), }); for (const event of data?.data ?? []) { console.log(event.created_at, event.actor_label, event.action, event.target_label); } ``` An `action` ending in `.` matches every action of that resource (`domain.`). Pages are newest first: pass the last event's `id` as `after` while `has_more` is true. The audit log is organization-wide: an API key of any environment, live or sandbox, reads every event of the organization. Only owners and admins can create API keys, and both can read the audit log. Reading it is not itself logged, and its events never appear in an environment's logs. # Plans and billing (/docs/billing) Every organization is on a plan. A paid plan's monthly fee is **usage credit**: it is spent on usage of any COSS product, so a month costs the larger of the fee and your usage (plus any add-ons). The Free plan has no credit but includes a monthly allowance. | | Free | Pro | Scale | Enterprise | | --- | --- | --- | --- | --- | | Price | Free | $25/month | $250/month | From $2,000/month, annual commitment | | Included | 3,000 email recipients a month | $25 of usage | $250 of usage | Per contract | | Usage beyond it | Not available | Usage rates below | Usage rates below | Contract rates | | Sending rate | 2 emails a second, 3,000 a day | 14 emails a second, 100,000 a day | 50 emails a second, 1,000,000 a day | Custom | | Domains | 1 | 10 | 100 | Custom | | Team members | 3 | 10 | Unlimited | Unlimited | | Webhook endpoints per environment | 2 | 10 | 10 | Custom | | Custom tracking domains | — | Yes | Yes | Yes | | Audit log | — | Yes | Yes | Yes | | Log drains | — | — | Yes | Yes | | Single sign-on (SAML, OIDC) | — | — | Yes | Yes | | Directory sync (SCIM) | — | — | — | Yes | See what your volume would cost on the [pricing page](/pricing). Enterprise plans are sold as annual contracts: custom rates, committed volumes, invoicing with payment terms, and direct debit. [Contact sales](mailto:sales@coss.com). ## Usage [#usage] Usage is counted per product and billed at these rates: | Product | Rate | | | --- | --- | --- | | Email | $0.40 per 1,000 recipients for the first 1,000,000; $0.30 per 1,000 recipients from 1,000,001 to 10,000,000; $0.22 per 1,000 recipients beyond 10,000,000 | | | SMS (US) | $0.0135 per segment | Committed volume, Enterprise only | | Video | $1.50 per 1,000 participant-minutes | | | Scheduling | $0.10 per booking | | - **Email** is counted per recipient handed to the email provider: every address in `to`, `cc` and `bcc` once, except suppressed addresses. Emails that fail before sending, or are canceled, cost nothing. Sandbox environments are never billed. - **Volume pricing is graduated**: each band is priced at its own rate, so there's no cliff. With 2,000,000 emails in a month, the first 1,000,000 cost $0.40 per 1,000 and the next 1,000,000 $0.30 per 1,000: $700. - Usage is counted in calendar months (UTC), and the bands start over each month. ## How a month is billed [#how-a-month-is-billed] Your plan's fee is charged at the start of each month, and becomes that month's credit. At the end of the month, its usage is rated, the credit is applied, and anything beyond it is charged on the next month's invoice with the next fee. For example, on Pro with 2,000,000 emails in October: | | | | --- | --- | | October fee (charged October 1) | $25.00 | | October usage | $700.00 | | October credit | -$25.00 | | **Charged November 1** (with November's $25 fee) | **$675.00** | Over the month you paid $700: the larger of the fee and the usage. ## Changing plans [#changing-plans] - **Upgrades** take effect as soon as they're paid: you pay the difference for the rest of the month, and get the same difference as extra credit. - **Downgrades and cancellations** take effect at the end of the month, and can be undone until then. Remove what the lower plan doesn't include first (for example, single sign-on or log drains): a downgrade is refused while you still use it. - If a month starts on Free and you upgrade, Free's allowance still covers its first emails. ## Limits and paused sending [#limits-and-paused-sending] - On **Free**, live sends past the month's allowance are refused with a `402` `billing_error` (`usage_limit_exceeded`) until the next month or an upgrade. - If an invoice can't be collected, we retry it and email your billing contacts. Once it's overdue past the grace period (14 days unless your contract says otherwise), live sending is paused (`402`, `billing_hold`) until it's paid. Sandbox keys keep working, and scheduled emails wait rather than fail. - Owners can set a **spend limit**: when usage beyond your credit reaches it, live sending pauses until the next month or until the limit is raised. Usage alerts go out at 80% and 100% of your credit (owners can change the thresholds). ## Credits [#credits] Besides your plan's credit, COSS support can add credits, in dollars or as free units of a product (for example, 50,000 free emails). Credits that apply to one product are used before general ones, and those expiring soonest go first. Your billing settings list every credit with what's left of it. ## Invoices and payment [#invoices-and-payment] Invoices are in US dollars. By default they're charged to your card; contracts can be invoiced with payment terms (bank transfer) or charged by direct debit. Billing settings show your invoices and their PDFs, and let owners update the payment method and billing details. ## Billing API and events [#billing-api-and-events] Read the organization's billing with any API key (billing is organization-wide, so every environment's key sees the same). Plan changes, payment methods and spend limits stay in the dashboard. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: billing } = await coss.billing.get(); // The plan, this month's usage and what it would cost so far (cents), standing and limits. console.log(billing?.plan, billing?.estimate.total, billing?.status); // ^? // Usage per day and environment, oldest first. const { data: usage } = await coss.billing.usage({ granularity: "day", group_by: "environment" }); // Invoices (newest first) and credits. const { data: invoices } = await coss.billing.invoices.list({ status: "open" }); const { data: credits } = await coss.billing.credits.list(); ``` Amounts are integers in cents; unit prices are dollar strings (`"0.0004"`). Lists page with `after` (the last `id`) while `has_more` is true. Usage covers up to 400 days per request (7 by hour) and is kept for 400 days. Subscribe a webhook endpoint to the `billing.*` [events](/docs/webhooks#organization-events) to follow invoices, payments, plan changes, usage alerts and paused sending as they happen: ```ts twoslash import { verifyWebhook } from "coss"; declare const body: string; declare const headers: Headers; declare function notifyFinance(message: string): void; // ---cut--- const event = await verifyWebhook(body, headers, process.env.COSS_WEBHOOK_SECRET!); if (event.type === "billing.invoice_payment_failed") { notifyFinance(`${event.data.invoice.number}: ${event.data.failure.message}`); } else if (event.type === "billing.usage_threshold_reached") { console.log(`${event.data.percent}% of this month's ${event.data.of} used`); } ``` Billing events go to live environments' endpoints only. `billing.plan_changed` is sent when a change is decided, with the moment it applies in `effective_at` (a downgrade's is the start of next month); `change` is `reverted` when a scheduled change is undone. # Data retention (/docs/data-retention) COSS keeps message data only as long as it is useful for delivery, debugging and abuse prevention. A background job deletes data once it passes the period below, for every organization and environment (sandbox and live alike). Deletion is permanent: COSS cannot restore deleted data. | Data | Kept | Details | | --- | --- | --- | | Emails | 45 days | Subject, bodies, addresses, headers, tags, recipient statuses, delivery events, opens, clicks and attachment names. Queued and scheduled emails are kept until they are sent or canceled. | | Attachment content | Until the email is sent | The bytes of attachments; only their name, type, size and checksum stay with the email. | | Received emails | 30 days | Emails sent to your receiving addresses: the raw message, headers, bodies, attachments and spam and authentication verdicts. You can also delete one right away. | | Idempotency keys | 24 hours | A request repeated with the same Idempotency-Key replays the first response. | | Webhook deliveries | 30 days | Event payloads, delivery statuses and attempt logs (including response excerpts). Deliveries still being retried are kept until they finish. | | Hooks messages | 30 days | Messages you send to your applications (payloads), their deliveries and attempt logs (including response excerpts). Deleting an application stops access to its messages at once; they are deleted with the rest of their day. | | Hooks portal sessions | 24 hours after expiry | Sign-ins to the portal you create for your applications' users (never the token itself). | | Log drain batches | 7 days | The lines sent to your log drains and their delivery attempts (including response excerpts). Lines that could not be sent within 3 days are dropped. | | Content screening results | 90 days | Verdicts of the automated abuse screening. The screened content itself is never stored. | | Reputation statistics | 31 days | Hourly bounce, complaint and Hooks delivery failure counts behind your account standing. | | Standing notification emails | 90 days | Records of the emails sent to owners and admins about a warning or suspension. | | Account standing history | 2 years | Warnings, suspensions and reinstatements. The latest change is kept while the organization exists. | | Risk assessments | 1 year | Decisions, scores, reasons, signals (ASN, country, counts) and the feedback you sent. Your user id stays until you ask COSS to forget the user. | | Risk assessment personal data | 90 days | The email address, IP address and user agent you sent with an assessment. Only keyed hashes remain afterwards. | | Risk memory | 1 year | Keyed hashes of mailboxes that have an account and of identifiers you reported as fraud or abuse (IP addresses count for 30 days), and the countries and networks each user logged in from (by keyed hash of your user id), counted from when they were last seen. | | Admin audit log | 2 years | Actions COSS staff take on organizations and users. | | Expired sessions and invitations | 7 days after expiry | Sign-in sessions, verification and reset tokens, and invitations that were never accepted. | | Invoices and payments | 10 years | Invoices with their line items, payments and credit notes, as tax law requires. Kept after your account is closed. | | Usage records | 400 days | Hourly counts of billable usage per environment. Each invoice keeps its month's totals. | | Billing notification emails | 90 days | Records of usage alerts and payment reminders sent to your billing contacts. | | Payment provider events | 30 days | Notifications from our payment processor about invoices and payments. | | Suppressions | Until you delete it | Addresses that sends skip. Never removed automatically, so a bounced address stays protected. | | Account and configuration | While your account exists | Users, organizations, projects, environments, API keys, domains, webhook endpoints, log drains, Hooks applications, event types and endpoints, risk allow and block lists, settings, and billing details (plan, contracts and credits). Items you delete (a key, a domain, an endpoint, a drain, an application) are removed right away. | Periods start when the data is created (for example, when you call `coss.email.send()`), unless the table says otherwise. Deletion runs continuously, so data may disappear shortly after its period ends, never before. ## Emails [#emails] An email is deleted 45 days after it was created, together with everything recorded about it: recipients and their statuses, delivery events, opens and clicks, tracked links and attachment names. Emails that are still queued or scheduled are kept until they are sent or canceled. After deletion: - `coss.email.get()`, `coss.email.listEvents()` and the dashboard return `not_found` for it. - If it had open or click tracking, its tracked links stop redirecting and its open pixel stops counting. - Suppressions created by its bounces and complaints stay in place: they still name the email's id, which no longer resolves. Attachment content is discarded much earlier: as soon as the email is sent, fails or is canceled. Only each attachment's name, type, size and checksum stay with the email. ## Received emails [#received-emails] A [received email](/docs/email/receiving) is deleted 30 days after it arrived, with its raw message, headers, bodies and attachments. Delete one sooner with `coss.email.received.delete()` or from its dashboard page: its content goes right away. Messages blocked for a virus never have their content stored. ## Webhook deliveries [#webhook-deliveries] Every event sent to your [webhook endpoints](/docs/webhooks), with its delivery status and the log of attempts (status codes, timing and up to 1 KB of each response), is deleted 30 days after the event. A delivery that is still being retried is kept until it succeeds or gives up. ## Keeping your own records [#keeping-your-own-records] To keep message history longer, store it on your side as it happens: subscribe a webhook endpoint to the events you need (`email.sent`, `email.delivered`, `email.bounced`, …) and record them in your own database. Event payloads include the email's id, sender, recipients, subject, tags and category. ## What is never deleted automatically [#what-is-never-deleted-automatically] - **Suppressions** stay until you remove them, so an address that bounced or complained stays protected however long ago that was. See [suppressions](/docs/email/suppressions). - **Account and configuration data**: your organization, its members, projects, environments, API keys, domains, webhook endpoints and settings. Anything you delete yourself (a key, a domain, an endpoint) is removed right away. ## Abuse prevention [#abuse-prevention] COSS screens a sample of live messages for abuse and keeps the verdict (never the content) for 90 days. Your account standing's history (warnings, suspensions and reinstatements) is kept for 2 years, and the latest change is kept for as long as the organization exists. # Delivery status (/docs/email/delivery) ## Email status [#email-status] `status` on the email says whether it was handed to the delivery provider: | Status | Meaning | | -------- | --------------------------------------------- | | `scheduled` | Waiting for `scheduled_at` | | `canceled` | Canceled before dispatch | | `queued` | Accepted by COSS, not yet handed over | | `sent` | Accepted by the provider (`sent_at` is set) | | `failed` | Could not be sent; see the `email.failed` event | ## Recipient status [#recipient-status] Delivery happens per recipient. `recipients` lists every distinct address across `to`, `cc` and `bcc`, each with its own `status`. A status only moves forward: `queued` → `sent` → `delivery_delayed` → `delivered`, then `bounced` / `suppressed` / `failed`, then `complained`. `suppressed` means the recipient was skipped because it is on the environment's [suppression list](/docs/email/suppressions), or the provider reported it as suppressed. An email whose recipients are all suppressed fails with `all_recipients_suppressed`. COSS automatically screens a sample of live emails for spam, phishing and other abuse right before sending them. An email it is confident about fails with `content_rejected` and live sending is suspended until COSS support reviews the account. Sandbox emails are never screened. COSS also emails your organization's owners and admins when it warns about your sending reputation or suspends live sending. ## Events [#events] Each change is recorded as an event. List them with `coss.email.listEvents` (see [`GET /v1/emails/{id}/events`](/docs/api-reference/emails/list-email-events)), or receive them as [webhooks](/docs/webhooks). ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: events } = await coss.email.listEvents("email_01k7c8m4q2f3e9v6t5r1n0p8yz"); for (const event of events?.data ?? []) { if (event.type === "email.bounced") { console.log(event.recipient, event.data.bounce?.type); // ^? } } ``` `email.sent`, `email.failed`, `email.scheduled`, `email.rescheduled` and `email.canceled` describe the whole email (`recipient` is `null`). Delivery events describe individual recipients. Open and click events name a recipient only when the email had exactly one non-suppressed recipient. # Domains (/docs/email/domains) Live keys can only send from domains your organization has verified. Domains belong to the organization and are shared by all of its projects. ## Adding a domain [#adding-a-domain] Add the domain in the dashboard (**Domains**) or through the API, then publish the DNS records it returns at your DNS provider: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: domain, error } = await coss.domains.create({ name: "acme.com" }); for (const record of domain?.records ?? []) { console.log(record.type, record.name, record.value); } ``` | Purpose | Type | Name | Value | | -------------------- | ------- | --------------------------------- | ---------------------------------------- | | DKIM (three records) | `CNAME` | `._domainkey.example.com` | `.dkim.amazonses.com` | | MAIL FROM | `MX` | `send.example.com` | `feedback-smtp..amazonses.com` (priority 10) | | MAIL FROM SPF | `TXT` | `send.example.com` | `v=spf1 include:amazonses.com ~all` | | DMARC (recommended) | `TXT` | `_dmarc.example.com` | `v=DMARC1; p=none;` | Copy the exact values from the dashboard or the `records` array: the DKIM tokens and region are specific to your domain. ## Verification [#verification] COSS checks the records automatically; the dashboard and each record's `status` show the result of the last check, and `can_send` says whether live keys may send from the domain. Verification usually takes minutes, but DNS changes can take longer to propagate. If a domain is not verified within 72 hours, verification fails. Restarting it (**Verify** in the dashboard, or `coss.domains.verify(id)`) issues new DKIM records to publish. When several organizations verify overlapping domains, the most specific one decides who may send: verifying `mail.example.com` lets you send from `@mail.example.com` even if someone else verified `example.com`. ## Tracking domain [#tracking-domain] A verified domain can serve open and click tracking links from its own subdomain, such as `links.acme.com`. See [custom tracking domains](/docs/email/tracking#custom-tracking-domains). # Receiving email (/docs/email/receiving) Every environment can receive email. Each message that arrives becomes a **received email** of the environment: an `email.received` [webhook](/docs/webhooks) announces it, and its headers, bodies, attachments and raw message are in the dashboard (**Received emails** on the environment's page) and the API. ## Addresses [#addresses] Each environment has its own domain under COSS's inbound domain, such as `k3j9x2m7qp4l.inbound.coss.com`. Email to **any** address at it (`support@…`, `orders+42@…`) is received, with no DNS setup: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: address } = await coss.email.received.address(); console.log(`Send a test email to hello@${address?.domain}`); ``` To receive at your own domain, set up receiving on one of your [domains](/docs/email/domains), at the domain itself or a subdomain such as `inbound.acme.com`, and publish the `MX` record it lists. The environment of the API key that sets it up (or the one you pick in the dashboard) receives the email: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const domainId: string; // ---cut--- const { data: domain, error } = await coss.domains.receiving.create(domainId, { hostname: "inbound.acme.com", }); const record = domain?.receiving?.record; console.log(record?.type, record?.priority, record?.value); // MX 10 inbound-smtp.us-east-1.amazonaws.com ``` - Pointing the domain itself (`acme.com`) at COSS moves **all** of its mail here, your team's mailboxes included. Use a subdomain unless that is what you want. - Each domain has at most one receiving hostname. It can't be the `send.` bounce subdomain or the domain's tracking domain. - Mail is received as soon as it reaches COSS; the MX check only shows what DNS serves. - Mail to an address nobody receives at is dropped without a bounce. ## Handling a received email [#handling-a-received-email] The `email.received` event says who sent what to whom, never the content. Fetch the email for its bodies and headers: ```ts twoslash import { Coss, verifyWebhook } from "coss"; const coss = new Coss(); declare const request: Request; declare const secret: string; // ---cut--- const event = await verifyWebhook(await request.text(), request.headers, secret); if (event.type === "email.received" && !event.data.spam) { const id = event.data.received_email_id; const { data: email } = await coss.email.received.get(id); console.log(email?.from, email?.subject, email?.text); for (const attachment of email?.attachments ?? []) { const { data: bytes } = await coss.email.received.getAttachment(id, attachment.id); console.log(attachment.filename, bytes?.byteLength); } } ``` - `recipients` are the envelope recipients your environment received the message for, Bcc'd addresses included. When one message is sent to several environments or organizations, each gets its own received email and only ever sees its own recipients. - `text` and `html` are cut at 2 MB each, and headers at 64 KB (`truncated` says so). `coss.email.received.getRaw()` returns the whole message as received (`message/rfc822`). - HTML comes from whoever sent the email: sanitize it before showing it to anyone. Attachments are served as `application/octet-stream` whatever they claim to be. - To reply in the same thread, send with the `In-Reply-To` and `References` headers set to the received email's `message_id`. ## Spam, viruses and authentication [#spam-viruses-and-authentication] Every message is scanned on arrival. `verdicts` holds the results of the spam and virus scans and of the SPF, DKIM and DMARC checks: `pass`, `fail`, `gray` (could not decide) or `processing_failed`. - Spam (`spam: true`) and messages that fail authentication are still received: decide what to do with them in your handler. - A message with a **virus** is `blocked`: only its envelope, sender, subject and verdicts are kept, its content is never stored, and no webhook is sent. In a sandbox environment, **Send a test email** on the **Received emails** page receives a message without sending one. Put `[coss-test:spam]`, `[coss-test:virus]`, `[coss-test:spf-fail]`, `[coss-test:dkim-fail]` or `[coss-test:dmarc-fail]` in its subject to try each verdict. ## Limits and retention [#limits-and-retention] - An organization receives up to 10,000 emails a day across its environments. Mail over the limit is dropped and counted on the **Received emails** page; contact support to raise it. - Messages can be up to 40 MB. - Received emails are kept for 30 days, then deleted with their content (see [data retention](/docs/data-retention)). `coss.email.received.delete()` deletes one right away. # Sending email (/docs/email/sending) ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.email.send( { from: "Acme ", to: ["ada@example.com", "Grace Hopper "], cc: "team@acme.com", reply_to: "support@acme.com", subject: "Your invoice", html: "

Your invoice is attached to your account.

", text: "Your invoice is attached to your account.", headers: { "X-Entity-Ref-ID": "invoice-123" }, tags: [{ name: "invoice_id", value: "invoice-123" }], category: "billing", }, { idempotencyKey: "invoice-123" }, ); ``` Sends return HTTP `202` with `status: "queued"`. A background worker hands the email to the provider. Follow delivery with `coss.email.get()` or the `email.sent` / `email.failed` webhooks. Emails are kept for 45 days (see [data retention](/docs/data-retention)). ## Scheduling, rescheduling and cancellation [#scheduling-rescheduling-and-cancellation] Pass `scheduled_at` as an ISO 8601 timestamp with a time zone offset, up to 30 days ahead. The response is HTTP `202` with `status: "scheduled"`; the worker checks for due emails every five seconds and rechecks the sender domain and suppression list before sending. A past timestamp uses the normal queue. Send quotas are charged when the schedule is accepted. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: scheduled } = await coss.email.send({ from: "hello@acme.com", to: "ada@example.com", subject: "Your reminder", text: "See you tomorrow!", scheduled_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }); if (scheduled) { // Send it a day later instead… await coss.email.reschedule(scheduled.id, { scheduled_at: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), }); // …or not at all. await coss.email.cancel(scheduled.id); } ``` Rescheduling moves a `scheduled` email to a new time, again at most 30 days from now, and emits `email.rescheduled` with the new and previous times in `data.reschedule`. A time that is not in the future sends the email within seconds; the email's current time returns it unchanged, without an event. Only `scheduled_at` can change: to change the content, cancel and send again. Rescheduling does not charge the send quota again. Canceling changes the status to `canceled` and emits `email.canceled`; repeating the cancellation returns the same email. Once dispatch starts, cancellation returns `422 email_not_cancelable` and rescheduling `422 email_not_reschedulable` (as does rescheduling a canceled email). Scheduling emits `email.scheduled`. These events describe the whole email. Suspending an organization holds its scheduled emails until the suspension is lifted. Attachment bytes remain stored while an email is scheduled and are deleted when it is sent, fails, or is canceled. ## Addresses [#addresses] `from`, `to`, `cc`, `bcc` and `reply_to` take `"user@example.com"` or `"Display Name "`. Each recipient field takes one address or an array of up to 50. With a live key, `from` must use a [verified domain](/docs/email/domains) of your organization. Sandbox keys accept any `from`. ## Content [#content] `subject` is required. Provide `html`, `text`, or both: sending both lets mail clients pick, and improves deliverability. ## Headers and tags [#headers-and-tags] - `headers`: up to 15 custom headers. Names are case-insensitive, and reserved names such as `subject` are rejected. - `tags`: up to 50 `name`/`value` pairs (letters, digits, `_` and `-`) for your own filtering. They are included in webhook payloads. ## Categories [#categories] `category` says what kind of email this is, such as `"billing"` or `"marketing"` (letters, digits, `_` and `-`). Recipients [suppressed](/docs/email/suppressions) for that category, or for every email, are skipped. It is stored on the email and included in webhook payloads. ## Sending in batches [#sending-in-batches] `coss.email.sendBatch()` sends up to 100 emails in one request. Each email takes the same fields as `coss.email.send()`, including `scheduled_at`, `attachments` and `tracking`, and they can have different senders and recipients. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.email.sendBatch( [ { from: "Acme ", to: "ada@example.com", subject: "Your weekly summary", html: "

Hi Ada, here is your week.

", category: "digest", }, { from: "Acme ", to: "grace@example.com", subject: "Your weekly summary", html: "

Hi Grace, here is your week.

", category: "digest", }, ], { idempotencyKey: "weekly-digest-2026-10-10" }, ); ``` The response is HTTP `202` with an `email_batch` whose `data` lists the emails in the order you passed them. Each email has the batch's id as `batch_id`, and is delivered and reported on (with webhooks) on its own. A batch is all or nothing. If any email is invalid, nothing is sent and the error's `issues` name it by position, such as `emails.3.to`. The same goes for an unverified sender domain, which is reported as `param: "emails.3.from"`. One idempotency key covers the whole batch. The batch's recipients count against the send quota together: a batch with more recipients than the quota allows at once is refused with no `Retry-After`, so split it into smaller batches. The 40 MB request size limit applies to the whole batch. ## Retrying safely [#retrying-safely] Every send carries an `Idempotency-Key`. The SDK generates one per call and reuses it across its automatic retries; pass your own (such as `invoice-123`) to stay safe across process restarts too. For 24 hours, repeating a request with the same key returns the original email instead of sending again. See [idempotency](/docs/api-reference/idempotency). # Suppressions (/docs/email/suppressions) Each environment has its own suppression list. When you send, every recipient (`to`, `cc` and `bcc`) with a matching entry is skipped: it gets the status `suppressed` and an `email.suppressed` event, and is never handed to the mail provider. If no recipient is left, the email fails with `all_recipients_suppressed`. ## Scopes [#scopes] An entry's `scope` is `*` (every email) or a [category](/docs/email/sending#categories). A send with `category: "marketing"` skips addresses suppressed for `*` or `marketing`; a send without a category only skips `*` entries. ## Automatic entries [#automatic-entries] A permanent bounce or a complaint adds a `*` entry for that address, with `reason` `bounce` or `complaint` and the `email_id` that caused it. Mailbox simulator addresses (`@simulator.amazonses.com`) are never suppressed, so they keep working for tests. ## Managing the list [#managing-the-list] Manage entries in the dashboard (**Suppressions**) or through the API. Adding an entry for an address and scope that already has one returns the existing entry. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- // A recipient unsubscribed from marketing email. await coss.email.suppressions.add({ address: "ada@example.com", scope: "marketing" }); // Would a marketing email reach them? const { data: check } = await coss.email.suppressions.check({ address: "ada@example.com", category: "marketing", }); // They resubscribed. if (check?.suppressed) { for (const entry of check.suppressions) { await coss.email.suppressions.remove(entry.id); } } ``` Removing an entry lets later sends reach the address again. Remove automatic entries with care: repeatedly sending to addresses that bounce or complain hurts your sending reputation. ## Importing and exporting [#importing-and-exporting] Move whole lists in and out, for example when you switch from another provider. `import` takes any number of entries and sends them 1,000 per request. Each entry is validated on its own: invalid ones are skipped and reported by their position, and entries already on the list are left unchanged, so importing the same list again is safe. Imported entries have the reason `import`. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: result } = await coss.email.suppressions.import({ suppressions: [ { address: "ada@example.com" }, { address: "grace@example.com", scope: "marketing" }, ], }); // result?.created, result?.existing, result?.invalid // The whole list as CSV: address,scope,reason,created_at,email_id,id (here only bounces). const { data: csv } = await coss.email.suppressions.export({ reason: "bounce" }); ``` The dashboard's **Suppressions** page imports a CSV file of up to 10,000 rows and downloads the same export. A file needs an `address` (or `email`) column and may have a `scope` (or `category`) column; a file without a header row has the address first and an optional scope second. An export imports again as is. Cells a spreadsheet would run as a formula (starting with `=`, `+`, `-` or `@`) are exported with a leading `'`, which the import removes. # Open and click tracking (/docs/email/tracking) COSS can record when recipients open an email (a 1×1 image at the end of the HTML) and click its links (each `http(s)` link is rewritten to a COSS redirect). Opens and clicks show up on the email in the dashboard and the API, and as `email.opened` and `email.clicked` webhook events. ## Turning it on [#turning-it-on] Tracking is off by default. Turn it on for an environment, or for one category of email, under **Tracking** in the environment's dashboard page or through the API: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- // Track every email of this environment… await coss.email.tracking.updateDefault({ opens: true, clicks: true }); // …except password resets. await coss.email.tracking.updateCategory("password-reset", { opens: false, clicks: false }); const { data: settings } = await coss.email.tracking.get(); console.log(settings?.default, settings?.categories); ``` Fields you leave out keep their value (a new rule starts with both off). `removeCategory` makes that category follow the default again, and `removeDefault` turns tracking off for emails without a category rule. Settings belong to the API key's environment, and changing them needs a key that can send email. An email keeps the settings that applied when it was sent. Or set it for a single send with `tracking`: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.email.send({ from: "Acme ", to: "ada@example.com", subject: "What's new in October", html: '

Read the blog.

', tracking: { opens: true, clicks: true }, }); ``` For each of `opens` and `clicks`, the send's own setting wins, then its category's rule, then the environment default. - Only HTML bodies are tracked; the plain-text part is sent unchanged. - Add `data-coss-no-track` to a link to leave it as is. `mailto:`, `tel:` and links containing merge tags are never rewritten. - Opens and clicks name a recipient only when the email had exactly one (not suppressed) recipient. - Mail clients that preload images inflate opens, and link scanners inflate clicks: treat the counts as signals, not exact numbers. ## Custom tracking domains [#custom-tracking-domains] By default, tracking links point at COSS's tracking host. A custom tracking domain serves them from a subdomain of your sending domain instead, such as `links.acme.com`, so the links in your emails match your brand and your From domain, and don't share a reputation with other senders. Set one up on a domain's page in the dashboard (**Domains**), or through the API: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const domainId: string; // ---cut--- const { data: domain, error } = await coss.domains.tracking.create(domainId, { hostname: "links.acme.com", }); const record = domain?.tracking?.record; console.log(record?.type, record?.name, record?.value); // CNAME links.acme.com track.cosslinks.com ``` Then publish the `CNAME` record at your DNS provider. COSS issues a certificate for the hostname once the record resolves, and checks it in the background; the tracking domain becomes `active` within minutes of the record propagating. Setup gives up after 72 hours; **Check now** in the dashboard (or `coss.domains.verify(id)`) starts it over. - Each domain has at most one tracking domain, and it must be a subdomain of that domain. - Emails use the tracking domain of their From domain (or of the closest verified parent domain) while it is `active`. While it isn't, they use COSS's tracking host, so tracking keeps working. - Removing a tracking domain, or its `CNAME` record, breaks the links in emails already sent through it. Keep the record in place for as long as those emails matter. # Environments and API keys (/docs/environments) ## Organizations and projects [#organizations-and-projects] Everything in COSS belongs to an **organization**. Members are owners, admins or members. An organization has any number of **projects**, and each project has two **environments**: | Environment | Mode | Key prefix | Sends email | | ------------- | --------- | ------------ | ------------------------------------------------ | | `production` | `live` | `coss_live_` | Yes, from your organization's verified domains | | `development` | `sandbox` | `coss_test_` | No. Emails are recorded with `sandbox: true` | ## API keys [#api-keys] An API key belongs to exactly one environment, and everything it creates or reads lives in that environment. A key can only read emails sent from its own environment. Keys are secrets. Call COSS from your server, never from a browser: the SDK refuses to run where a `document` global exists unless you opt in with `dangerouslyAllowBrowser`. Creating a live key requires a verified email address on your account. ## Sandbox behaviour [#sandbox-behaviour] Sandbox keys accept any `from` address and never deliver anything. Delivery events are simulated the way the Amazon SES mailbox simulator behaves, so you can test failure handling: | Recipient | Simulated events | Recipient status | | ------------------------------------------ | ------------------------------------------------- | ---------------- | | any address outside `simulator.amazonses.com` | `email.delivered` | `delivered` | | `bounce@simulator.amazonses.com` | `email.bounced` (permanent) | `bounced` | | `complaint@simulator.amazonses.com` | `email.delivered`, then `email.complained` | `complained` | | `suppressionlist@simulator.amazonses.com` | `email.bounced` (on the suppression list) | `suppressed` | Every sandbox email also gets `email.sent`. # Delivery, retries and limits (/docs/hooks/delivery) ## Retries [#retries] A delivery that fails (no 2xx response, a timeout, a network error) is retried after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: 8 attempts over roughly a day, each a little earlier at random so that many failed deliveries don't retry at once. Then it is `failed`. Retry one by hand with `coss.hooks.deliveries.retry()`, or from the portal or dashboard. ## Failing endpoints are disabled [#failing-endpoints-are-disabled] When an endpoint's deliveries keep failing every attempt for 5 days, COSS disables it. Its `disabled_reason` is `failing`, and your own [webhook endpoints](/docs/webhooks) subscribed to `hook_endpoint.disabled` are told, so you can let your customer know. Enabling it again (in the portal, the dashboard or with `coss.hooks.endpoints.update()`) clears its record. ## Recover what an endpoint missed [#recover-what-an-endpoint-missed] After an outage, requeue every delivery that failed since a time, once each, oldest first: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const endpointId: string; // ---cut--- const { data: recovery } = await coss.hooks.endpoints.recover("customer_8812", endpointId, { since: new Date(Date.now() - 6 * 60 * 60 * 1000), }); // recovery.status is "pending" while COSS requeues them in the background. ``` ## Limits [#limits] | Limit | Live | Sandbox | | --------------------------------------- | ----------------------------- | ------------------ | | Messages per second (organization) | 50, bursts of 200 | — | | Deliveries per day (fan-out) | 100,000 per organization | 2,000 per environment | | Applications per environment | 100,000 | 100 | | Endpoints per application | 50 | 5 | | Event types per environment | 1,000 | 1,000 | | Payload size | 256 KB | 256 KB | | Requests per endpoint | 20 per second (or the endpoint's `rate_limit_per_second`) | same | A send that goes over a quota fails with `429 quota_exceeded` (a replay of an earlier send still answers). Deliveries over an endpoint's or a destination host's rate wait instead of failing. Contact support for higher limits. ## Reputation [#reputation] COSS watches the share of live deliveries that fail for good. A very high rate warns your organization (see [reputation](/docs/api-reference/reputation/get-reputation)): usually it means endpoints that no longer exist, which you should disable or delete. Sending is never suspended for it by default. # Hooks (/docs/hooks) Hooks lets your product send webhooks to **your** customers without building the machinery: you send a message to one of your customers once, and COSS delivers it to every URL they registered, signed, retried with backoff for about a day, and logged. A ready-made [portal](/docs/hooks/portal) lets your customers add endpoints, pick event types, see every delivery and retry them, without you building any UI. Not to be confused with [webhooks](/docs/webhooks), which tell **you** about your own emails. | Concept | What it is | | --------------- | ---------------------------------------------------------------------------------------------- | | Application | One of your customers (or users). Address it by its `id` or your own `uid` for it. | | Event type | A kind of message you send, e.g. `invoice.paid`: the catalog your customers subscribe from. | | Endpoint | A customer's URL that receives their application's messages, with its own signing secret. | | Message | One event you send to one application; its JSON `payload` is the request body. | | Delivery | One message sent to one endpoint, with every HTTP attempt. | Everything belongs to the API key's [environment](/docs/environments): sandbox (`coss_test_…`) keys deliver to real URLs too, under much lower limits. ## Quickstart [#quickstart] Register the event types you send (once), then create an application for each customer: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- await coss.hooks.eventTypes.create({ name: "invoice.paid", description: "An invoice was paid in full.", examples: [{ invoice_id: "in_1042", amount: 4200 }], }); await coss.hooks.applications.create({ name: "Acme Corp", uid: "customer_8812", // your own id for this customer }); ``` Your customer adds their endpoints in the [portal](/docs/hooks/portal), or you add them for them: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: endpoint } = await coss.hooks.endpoints.create("customer_8812", { url: "https://acme.example/webhooks", filter_types: ["invoice.paid"], // omit for every type }); // endpoint.secret is how Acme verifies requests: show it to them, or let them read it in the portal. ``` Then send messages whenever something happens: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.hooks.messages.send({ application: "customer_8812", event_type: "invoice.paid", payload: { invoice_id: "in_1042", amount: 4200, currency: "usd" }, }); ``` The send returns `202` right away; COSS delivers the payload to each of the application's enabled endpoints subscribed to `invoice.paid`, usually within a second. Follow it with `coss.hooks.messages.listDeliveries()`, or in the dashboard under **Hooks**. The SDK sends an `Idempotency-Key` with every send and reuses it when it retries, so a retried send is delivered once. Pass your own (`{ idempotencyKey: "invoice-1042-paid" }`) to make your own retries safe too (see [idempotency](/docs/api-reference/idempotency)). ## Event types [#event-types] Messages can use any `event_type` (1–64 letters, digits and `. _ -`), registered or not, but endpoints only subscribe to registered ones, and the portal shows your customers each type's description, JSON Schema and examples. Archiving a type hides it from the portal and new subscriptions; endpoints that already subscribed keep receiving it. ## Payloads [#payloads] A payload is any JSON object up to 256 KB. COSS parses it and serializes it again, so keys keep their order but whitespace doesn't, and numbers beyond 2^53 lose precision: send large integers (and amounts you can't round) as strings. Messages, deliveries and attempts are kept for 30 days. # Portal (/docs/hooks/portal) The portal is a page, on its own domain, where one application's users add and edit endpoints, choose event types, reveal and roll signing secrets, browse every message and delivery attempt, retry deliveries, recover the ones that failed while their server was down, and send test messages. You open it from your product, or embed it in your own settings page. ## Open it [#open-it] Create a portal session from your backend each time a user opens the portal, and send them to its `url` (never create sessions in the browser: that needs your API key). ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: session } = await coss.hooks.portal.createSession({ application: "customer_8812", expires_in: 3600, // seconds; at most a day }); // Redirect the user to session.url, or embed it. ``` The URL carries the session's secret token in its fragment (`#cossp_…`), which browsers never send to a server, so it doesn't end up in logs. The portal keeps it for the tab and removes it from the address bar. Anyone with the URL can use the session until it expires: hand it only to that application's user. Pass `read_only: true` for a session that can look but not change anything. ## Embed it [#embed-it] ```html ``` Browsers only show the portal inside an iframe on origins you allow: add your site's origin (for example `https://app.example.com`) under **Hooks → Portal settings** in the dashboard. Without any, the portal only opens as its own page. The same settings set the name, accent color and theme it shows. ## What changes in the portal [#what-changes-in-the-portal] Everything a user does in the portal goes through the same rules as the API: endpoint URLs must be public `https://` URLs, filter types must be registered event types, and test sends count against your send quota. Changes appear in your organization's [audit log](/docs/audit-log) as made by the API key (or member) that opened the portal, labeled `Portal · application `. # Receiving messages (/docs/hooks/receiving) Each delivery is an HTTPS `POST` to the endpoint with: - the message's payload as the JSON body (`Content-Type: application/json`); - [Standard Webhooks](https://www.standardwebhooks.com) headers: `webhook-id` (the message id, the same on every retry and for every endpoint), `webhook-timestamp` (Unix seconds) and `webhook-signature` (`v1,`, one per active secret, space-separated); - `coss-event-type`: the message's event type; - `User-Agent: COSS-Hooks/1.0`. Any 2xx response within 15 seconds counts as delivered; only the first 1 KB of the response is kept. Redirects are not followed. ## Verify every request [#verify-every-request] Verify the signature over the raw body with the endpoint's secret (`whsec_…`) before trusting it. Any Standard Webhooks library works, or the `coss` package: ```ts twoslash import { verifyWebhook, WebhookVerificationError } from "coss"; declare const request: Request; declare const ENDPOINT_SECRET: string; type InvoicePaid = { invoice_id: string; amount: number }; // ---cut--- try { const payload = await verifyWebhook( await request.text(), // the raw body, not re-serialized JSON request.headers, ENDPOINT_SECRET, ); // payload.invoice_id … } catch (error) { if (error instanceof WebhookVerificationError) { // Reject: not from your provider, or too old (a replay). } throw error; } ``` Requests older than 5 minutes fail verification, which stops replays. During a secret roll each request carries a signature for both secrets, so receivers keep working while they switch. ## Duplicates and order [#duplicates-and-order] Retries mean a receiver can see a message more than once: use `webhook-id` to skip ones it already handled. Messages are not delivered in order, especially across retries; carry a timestamp or version in the payload if order matters. # Introduction (/docs) COSS puts email, and soon SMS, video and scheduling, behind a single API key and a single SDK. You create an organization, add a project, and call every product with the same key and the same client: ```ts import { Coss } from "coss"; const coss = new Coss({ apiKey: process.env.COSS_API_KEY }); await coss.email.send({ from: "Acme ", to: "user@example.com", subject: "Welcome", html: "

Hi there

", }); ``` Send your first email in a few minutes. Install `coss` and learn the client. Every endpoint, with SDK and HTTP examples. Live and sandbox keys, and what each one can do. ## Products [#products] | Product | Status | | ---------- | ----------- | | Email | Available | | SMS | Coming soon | | Video | Coming soon | | Scheduling | Coming soon | # Log drains (/docs/log-drains) A log drain is an HTTPS endpoint that receives an environment's [log](/docs/logs) as it is written: every API request, email event and webhook attempt, plus the changes made to the environment (from the organization's audit log). COSS posts them in batches, usually within a few seconds, signed like [webhooks](/docs/webhooks). | Source | Lines | | --------- | ---------------------------------------------------------------------------------------------- | | `api` | Requests made with the environment's API keys (`api.request`) | | `email` | Email events: `email.sent`, `email.delivered`, `email.bounced`… | | `webhook` | Delivery attempts to the environment's webhook endpoints (`webhook.attempt`) | | `audit` | Changes to the environment: API keys, webhook endpoints, log drains, suppressions, tracking… | A drain takes every source unless you list some. Organization-wide changes (members, domains, SSO) stay in the organization's [audit log](/docs/audit-log) and are not sent to drains. ## Create a drain [#create-a-drain] In the dashboard, open **Log drains** in an environment. Or with the API: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.logDrains.create({ url: "https://logs.example.com/coss", sources: ["api", "email"], }); // Store data.secret: it is returned only now. ``` Drains belong to the API key's environment, like webhook endpoints. An environment can have up to 5 drains. ## What COSS sends [#what-coss-sends] Each request is a `POST` with `Content-Type: application/x-ndjson`: one JSON object per line, each line ending with a newline, at most 500 lines or 1 MB per request. Log lines are exactly the objects [`GET /v1/logs`](/docs/api-reference/logs/list-logs) returns (`"object": "log"`), and audit events the objects of the [audit log](/docs/audit-log) (`"object": "audit_event"`): ```json {"object":"log","id":"log_01k7c8m4…","occurred_at":"2026-01-15T09:30:00.000Z","source":"api","level":"info","type":"api.request","message":"POST /v1/emails 202","request_id":"req_01k7c8m4…","status_code":202,…} {"object":"audit_event","id":"oaudit_01k7c8n1…","created_at":"2026-01-15T09:30:02.000Z","actor_type":"api_key","actor_id":"key_01k7…","action":"webhook_endpoint.created","target_type":"webhook_endpoint","target_id":"whep_01k7…",…} ``` The headers carry a [Standard Webhooks](https://www.standardwebhooks.com) signature over the body (`webhook-id`, `webhook-timestamp`, `webhook-signature`), plus `coss-log-drain-id` and `coss-environment-id`. Verify every request with the drain's secret before storing its lines: ```ts twoslash import { verifyLogDrainBatch } from "coss"; declare const request: Request; // ---cut--- const lines = await verifyLogDrainBatch( await request.text(), request.headers, process.env.COSS_LOG_DRAIN_SECRET ?? "", ); for (const line of lines) { if (line.object === "log") console.log(line.occurred_at, line.level, line.message); else console.log(line.created_at, line.actor_label, line.action); } ``` `verifyLogDrainBatch` throws `WebhookVerificationError` when the signature doesn't match or the request is more than five minutes old: answer with a 4xx status then. ## Delivery [#delivery] - Answer with a 2xx status once the lines are stored. Anything else (or no answer within 15 seconds) is retried after 5 seconds, 1 minute, 5 minutes, 30 minutes, 1, 2, 4, 8 and 8 hours. - A batch can arrive more than once: the `webhook-id` header is the batch's id and stays the same on every retry, and each line has its own `id`. Skip what you already stored. - Batches go out one at a time per drain, in the order lines were written. API request lines are written within about a second of the response, so they can follow the email events a request caused: sort by `occurred_at` (audit events: `created_at`) if order matters. Lines logged while a batch is being retried wait for it. - If a batch fails all 10 attempts (about a day), COSS disables the drain and drops the lines still waiting for it; the audit log records `log_drain.disabled`. Fix the endpoint, then enable the drain again: it receives lines logged from then on. - Disabling a drain yourself also drops its waiting lines, and nothing is kept for a disabled drain. - While an organization is suspended, its drains are held; lines that wait more than 3 days are dropped. Each drain's page in the dashboard (or `coss.logDrains.batches.list()`) shows its recent batches with every attempt, its status code and the start of the response. Batches are kept for 7 days. # Logs (/docs/logs) Each environment has a log. It records: | Source | Lines | | --------- | -------------------------------------------------------------------------------------------- | | `api` | Every request made with one of the environment's API keys (`api.request`) | | `email` | Every email event: `email.sent`, `email.delivered`, `email.bounced`, `email.opened`… | | `webhook` | Every delivery attempt to one of the environment's webhook endpoints (`webhook.attempt`) | Lines have a level: `error` for 5xx responses, bounces, complaints and failures, `warning` for 4xx responses, delays, suppressions and webhook attempts that will be retried, `info` for everything else. Lines about the same email share its `resource_id`, so a send request, the email's events and the webhook attempts they caused read as one timeline. Open **Logs** in an environment of the dashboard to search, filter, follow new lines live and export them as CSV or NDJSON. Or read them through the API: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data, error } = await coss.logs.list({ from: new Date(Date.now() - 24 * 60 * 60 * 1000), level: ["warning", "error"], source: "api", }); for (const line of data?.data ?? []) { console.log(line.occurred_at, line.status_code, line.message, line.request_id); } ``` Pages are newest first: pass the last line's `id` as `after` while `has_more` is true. A query covers an hour unless you pass `from`/`to`, and at most 31 days. Reading logs is not itself logged. ## Searching [#searching] The dashboard's search box takes `key:value` filters and free text. Repeating a key matches any of its values; `-key:value` excludes. | Filter | Matches | | ------------------------- | ------------------------------------------------ | | `level:error` | `info`, `warning`, `error` | | `source:email` | `api`, `email`, `webhook` | | `status:5xx`, `status:404`| A status class or code | | `method:POST` | The request method | | `route:/v1/emails/:id` | The matched route | | `path:/v1/domains` | A path prefix | | `type:email.bounced` | A line type | | `key:key_…` | Requests made with one API key | | `request:req_…` | One request (the `X-Request-Id` header) | | `resource:email_…` | Lines about one email, domain or endpoint | | `recipient:user@…` | Email events of one recipient | ## What is kept [#what-is-kept] Request and response bodies are stored redacted: secrets (such as a webhook endpoint's signing secret), attachment contents and the `Authorization` header are never kept, long strings are cut to 2,048 characters and bodies to 16 KB. Requests that fail authentication can't be attributed to an environment and are not logged. Logs are kept for 30 days. To keep them longer, or to search them next to your own logs, stream them to your logging stack with a [log drain](/docs/log-drains). # Quickstart (/docs/quickstart) ### Create an API key [#create-an-api-key] In the dashboard, open your project's **Development** environment and create an API key. Development keys start with `coss_test_`: emails sent with them are recorded but never delivered, so you can experiment freely. See [Environments and API keys](/docs/environments). ```bash export COSS_API_KEY=coss_test_... ``` ### Install the SDK [#install-the-sdk] ```package-install coss ``` The SDK is not published to npm yet. Until it is, use it from the COSS repository. ### Send an email [#send-an-email] The client reads `COSS_API_KEY` from the environment when you don't pass `apiKey`. ```ts twoslash title="send.ts" import { Coss } from "coss"; const coss = new Coss(); const { data, error } = await coss.email.send({ from: "Acme ", to: "user@example.com", subject: "Hello from COSS", html: "

It works!

", }); if (error) { console.error(error.code, error.message); } else { console.log(data.id, data.status); // ^? } ``` Every method returns `{ data, error }` and never throws for API or network failures.
### Follow delivery [#follow-delivery] Each recipient has its own delivery status. Fetch the email again, or [subscribe to webhooks](/docs/webhooks) to be told when it changes. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: email } = await coss.email.get("email_01k7c8m4q2f3e9v6t5r1n0p8yz"); for (const recipient of email?.recipients ?? []) { console.log(recipient.address, recipient.status); } ```
When you are ready to deliver real email, [verify a domain](/docs/email/domains) and switch to a key from the **Production** environment. # Risk assessments (/docs/risk) Call `coss.risk.assess()` from your server at the moments abuse happens (a sign-up, a login, a message, a checkout) and act on the decision it returns: - `allow`: go ahead. - `review`: suspicious. Let it through with limits (no free credits, email verification first) or hold it for a person. Reviews show up in the dashboard's **Risk** page and fire a [`risk.assessment.review` webhook](/docs/webhooks#events). - `block`: refuse it. Every assessment comes with a `score` (0-1) and the `reasons` behind it, so you can see and explain every decision. COSS combines: - **Email signals**: syntax, disposable domains, domains that can't receive mail, role accounts (`info@`), free providers, typos (`gmail.con`) and domains registered in the last 30 days. - **IP signals**: Tor exit nodes, cloud and hosting networks, networks used by VPNs, private addresses, the network (ASN) and country. - **Your captcha**: the token of your Turnstile or hCaptcha widget, verified with the provider ([settings](/docs/risk/settings#captcha)). - **What this environment has seen**: how often the same IP address, subnet, email domain, mailbox or user came by in the last hour and day; whether the mailbox already has an account (dots and `+tags` folded at Gmail and similar); the countries and networks each user logged in from; and identifiers you reported as fraud. - **Your [allow and block lists](/docs/risk/lists-and-feedback#lists)** and **[rules](/docs/risk/rules)**, and your [settings](/docs/risk/settings). ## Assessing a sign-up [#assessing-a-sign-up] ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const form: { email: string }; declare const request: { ip: string; userAgent: string }; declare function createAccount(email: string): Promise<{ id: string }>; // ---cut--- const { data: assessment, error } = await coss.risk.assess({ event: "signup", user: { email: form.email }, ip: request.ip, user_agent: request.userAgent, }); // Fail open: an outage on our side must never lock your users out. if (error) console.error(error); if (assessment?.decision === "block") { throw new Error("We couldn't create your account."); } const typo = assessment?.reasons.find((reason) => reason.code === "email.typo"); if (typo) { // The one reason worth showing the user: "Did you mean ada@gmail.com?" console.log(typo.suggestion); } const account = await createAccount(form.email); ``` Pass the client's IP address and user agent as your server received them, not your load balancer's (an `ip.private` reason means it got the proxy's). Don't show reasons to the person being assessed, apart from a typo suggestion: they tell an attacker what to change. Once the account exists, pass its id with later events (`user: { id }`). Sign-ups that carry a user id are what duplicate detection compares: a second account for the same mailbox gets `duplicate.canonical_mailbox` with the first user's id. If you can choose the id before the account is created, pass it with the sign-up itself. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const session: { userId: string; ip: string; userAgent: string }; // ---cut--- const { data } = await coss.risk.assess({ event: "login", user: { id: session.userId }, ip: session.ip, user_agent: session.userAgent, }); ``` Other events are `message`, `checkout` and `custom` (with an `event_name` such as `invite_sent`). Velocity is counted per event, so a burst of invitations doesn't count as sign-ups. ## Retries and failures [#retries-and-failures] The SDK retries network errors and server errors with the same `Idempotency-Key`, and a retried assessment returns the first result without counting the event again. Assessments are limited to 100 per second per API key (bursts of 200). When a signal is unavailable (a slow DNS lookup, missing IP data), the assessment is made without it and the group is listed in `degraded`; it never fails because of it. ## What is stored [#what-is-stored] Assessments keep the email address, IP address and user agent for 90 days, then only keyed hashes of them; decisions, reasons and signals stay for 1 year. When you delete one of your users, call `coss.risk.users.forget(userId)` (see [Forgetting users](/docs/risk/lists-and-feedback#forgetting-users)). # Lists, feedback and reviews (/docs/risk/lists-and-feedback) ## Lists [#lists] Each environment has an allow list and a block list. An entry matches on one of: | Type | Matches | | --------- | ----------------------------------------------------------------------- | | `email` | The mailbox, with dots and `+tags` folded at Gmail and similar providers | | `domain` | The email domain and its subdomains | | `ip` | An address or a CIDR range (at most a /8, or a /16 for IPv6) | | `asn` | A network, e.g. `AS64500` | | `country` | An ISO 3166-1 alpha-2 code, e.g. `DE` | | `user` | Your user id | ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- await coss.risk.lists.add("block", { domain: "evil.example", note: "Card testing" }); await coss.risk.lists.add("allow", { email: "qa+signup@acme.com" }); await coss.risk.lists.add("block", { ip: "203.0.113.0/24", expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }); ``` Adding an entry that exists returns it unchanged. An entry with `expires_at` stops matching then (it stays listed until you remove it). The dashboard's **Risk → Allow & block lists** page manages the same lists. ## Feedback [#feedback] Tell COSS what an assessed user turned out to be. `fraud` and `abuse` make later assessments in the environment flag the same mailbox (`history.fraud_mailbox`), user (`history.fraud_user`), IP address (`history.fraud_ip`) and subnet (`history.fraud_subnet`); IP addresses and subnets count for 30 days. `legitimate` takes back what that assessment reported. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const assessmentId: string; // ---cut--- await coss.risk.feedback(assessmentId, { outcome: "fraud", note: "Chargeback" }); ``` ## Reviews [#reviews] `review` decisions stay open until you send feedback for them. Find them in the dashboard (**Risk → Review queue**, where each assessment has Legitimate, Abuse and Fraud buttons), with `coss.risk.assessments.list({ review_status: "open" })`, or as they happen with a webhook endpoint subscribed to `risk.assessment.review`. Resolving one (feedback from the API or the dashboard) sends `risk.assessment.reviewed` with the `outcome`, so a queue you keep elsewhere can close it. Payloads have the assessment's id, event, decision, score, reasons and your user id, never the email or IP address: ```json { "id": "evt_01k7c8m4q2f3e9v6t5r1n0p8yz", "object": "event", "type": "risk.assessment.review", "api_version": "2026-10-10", "created_at": "2026-10-14T12:00:00.000Z", "data": { "assessment_id": "rsk_01k7c8m4q2f3e9v6t5r1n0p8yz", "event": "signup", "event_name": null, "decision": "review", "score": 0.7, "reasons": [ { "code": "email.disposable", "message": "The email domain belongs to a disposable-mail service." } ], "user_id": "u_42", "outcome": null, "occurred_at": "2026-10-14T12:00:00.000Z" } } ``` ## Forgetting users [#forgetting-users] When you delete one of your users, ask COSS to forget them: their mailbox, their fraud reports and the user id, email address, IP address and user agent on their assessments are deleted. Decisions and reasons stay, without anything that identifies the user. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- await coss.risk.users.forget("u_42"); ``` # Reasons and scores (/docs/risk/reasons) Each reason that fires adds its weight to the score: `score = 1 − Π(1 − weight)`, so independent signals reinforce each other and the score never goes down when a reason is added. With the default thresholds, a score of 0.5 or more is `review` and{" "} 0.85 or more is `block`. For example, a disposable address alone (0.7) is `review`; a disposable address over Tor (`1 − 0.3 × 0.5 = 0.85`) is `block`. [Lists](/docs/risk/lists-and-feedback#lists) decide before the score: an allow-list match is always `allow` (the other reasons are still reported), a block-list match is always `block`. Reasons come most significant first. Each has a `code`, a `message` for developers, and the details named below (`value`, `suggestion`, `provider`, `previous_user`…). | Code | Weight | Meaning | | --- | --- | --- | | `email.invalid` | 0.6 | The email address is not syntactically valid. | | `email.disposable` | 0.7 | The email domain (or a parent domain) belongs to a disposable-mail service. | | `email.undeliverable` | 0.6 | The email domain can't receive mail: it doesn't exist, has a null MX, or has no MX, A or AAAA record. | | `email.role_account` | 0.15 | A shared or system mailbox (`info@`, `admin@`, `noreply@`…) rather than a person. | | `email.typo` | 0.2 | The domain looks like a typo of a popular one; `suggestion` holds the likely domain, which you may show the user. | | `email.domain_new` | 0.35 | The email domain was registered in the last 30 days (from its registry, over RDAP). | | `ip.tor` | 0.5 | The IP address is a Tor exit node. | | `ip.vpn_network` | 0.3 | The IP address belongs to a network mostly used by commercial VPN and proxy services (`provider`). | | `ip.hosting_provider` | 0.25 | The IP address belongs to a cloud or hosting provider (`provider`): usually a script or a proxy, rarely a person. | | `ip.private` | 0 | The IP address is private or reserved: you probably passed your load balancer's or proxy's address instead of the client's. | | `velocity.signups_per_ip_1h` | 0.35 | Many sign-ups from this IP address in the last hour (`value`). Fires at 5 or more. | | `velocity.signups_per_ip_24h` | 0.3 | Many sign-ups from this IP address in the last 24 hours (`value`). Fires at 20 or more. | | `velocity.signups_per_subnet_1h` | 0.25 | Many sign-ups from this IPv4 /24 or IPv6 /64 in the last hour (`value`). Fires at 15 or more. | | `velocity.signups_per_email_domain_1h` | 0.2 | Many sign-ups with this (non-free) email domain in the last hour (`value`). Fires at 20 or more. | | `velocity.signups_per_mailbox_24h` | 0.3 | Repeated sign-up attempts for the same mailbox (dots and `+tags` folded) in the last 24 hours (`value`). Fires at 3 or more. | | `velocity.logins_per_ip_1h` | 0.3 | Many logins from this IP address in the last hour (`value`). Fires at 30 or more. | | `velocity.logins_per_user_1h` | 0.3 | Many logins to this user in the last hour (`value`). Fires at 10 or more. | | `velocity.events_per_ip_1h` | 0.25 | Many events of this kind from this IP address in the last hour (`value`). Fires at 60 or more. | | `velocity.events_per_user_1h` | 0.25 | Many events of this kind from this user in the last hour (`value`). Fires at 60 or more. | | `duplicate.canonical_mailbox` | 0.5 | Another sign-up in this environment already used the same mailbox, with dots and `+tags` folded (`previous_user`, `previous_assessment`). | | `history.fraud_mailbox` | 0.7 | This mailbox was in an assessment you reported as fraud or abuse. | | `history.fraud_user` | 0.8 | This user was in an assessment you reported as fraud or abuse. | | `history.fraud_ip` | 0.45 | This IP address was in an assessment you reported as fraud or abuse in the last 30 days. | | `history.fraud_subnet` | 0.2 | This IPv4 /24 or IPv6 /64 was in an assessment you reported as fraud or abuse in the last 30 days. | | `login.new_country` | 0.3 | A login from a country this user never logged in from before in this environment. | | `login.new_network` | 0.15 | A login from a network (ASN) this user never logged in from before in this environment. | | `captcha.failed` | 0.6 | The captcha token was refused by the provider (expired, reused, or never solved). | | `captcha.missing` | 0.4 | No captcha token, while your settings require one (`captcha.required`). | | `rule.matched` | 0 | One of your rules matched (`rule`, `name`): its action is the decision, unless a list decided. | | `list.blocked` | 0 | An entry of your block list matched (`entry`, `type`): the decision is `block`. | | `list.allowed` | 0 | An entry of your allow list matched (`entry`, `type`): the decision is `allow`, whatever else fired. | ## Signals [#signals] Besides the reasons, `signals` reports what COSS found, whether or not it fired a reason: - `signals.email`: the domain, `valid`, `disposable`, `deliverability` (`deliverable`, `undeliverable` or `unknown`), `role_account`, `free_provider` and `suggestion`. - `signals.ip`: `version`, `private`, `tor`, `hosting` with `hosting_provider` and `hosting_kind` (`cloud`, `hosting` or `vpn`), `asn`, `as_name` and `country`. - `signals.velocity`: counts of the same event in this environment, this one included, keyed `_`: `ip_1h`, `subnet_24h`, `mailbox_24h`, `email_domain_1h`, `user_1h`… Email domains of free providers are not counted. IP data comes from public sources: the Tor Project's exit list, the address ranges AWS, Google Cloud, Oracle Cloud and DigitalOcean publish, a list of hosting networks COSS maintains, and [IPinfo Lite](https://ipinfo.io/lite) (ASN and country, CC BY-SA 4.0). They are refreshed every 30 minutes (Tor) to daily. # Rules (/docs/risk/rules) The score weighs every signal together. When you know exactly what you want for a case, write a rule. Rules are checked after your [lists](/docs/risk/lists-and-feedback#lists) and before the score thresholds. The first enabled rule (lowest `position`, then oldest) whose conditions match the event decides, and the assessment gets a `rule.matched` reason naming it. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: rule } = await coss.risk.rules.create({ name: "Disposable email from a data center", position: 10, events: ["signup"], conditions: [ { field: "email.disposable", operator: "is", value: true }, { field: "ip.hosting", operator: "is", value: true }, ], action: "block", }); // Checked first (lower position): trust logins from your office network. await coss.risk.rules.create({ name: "Office network", position: 0, events: ["login"], conditions: [{ field: "ip.asn", operator: "in", value: ["64500", "64501"] }], action: "allow", }); ``` - `match: "all"` (the default) needs every condition; `match: "any"` needs one. - `events` limits a rule to some events; empty means every event. - A field without a value never matches, whatever the operator: a rule about `ip.country` doesn't fire for an assessment without an IP address, and `neq` doesn't fire either. - Rules see the facts behind [disabled reasons](/docs/risk/settings#disabled-reasons), so you can turn a reason off for the score and still use it in a rule. - An environment has at most 50 rules. Edit them in the dashboard (**Risk → Rules**) or with `coss.risk.rules.update(id, { enabled: false })`. ## Fields [#fields] String comparisons ignore case; `in` and `not_in` take a list of up to 100 strings. | Field | Type | Operators | Meaning | | --- | --- | --- | --- | | `email.valid` | boolean | is | Email is valid | | `email.disposable` | boolean | is | Email is disposable | | `email.undeliverable` | boolean | is | Email domain can't receive mail | | `email.role_account` | boolean | is | Email is a role account | | `email.free_provider` | boolean | is | Email is at a free provider | | `email.typo` | boolean | is | Email domain looks like a typo | | `email.domain_new` | boolean | is | Email domain is new | | `email.domain` | string | eq, neq, in, not_in | Email domain | | `email.domain_age_days` | number | eq, neq, gt, gte, lt, lte | Email domain age (days) | | `ip.tor` | boolean | is | IP is a Tor exit | | `ip.hosting` | boolean | is | IP is a cloud or hosting network | | `ip.vpn` | boolean | is | IP is a VPN network | | `ip.private` | boolean | is | IP is private | | `ip.country` | string | eq, neq, in, not_in | IP country (ISO code) | | `ip.asn` | string | eq, neq, in, not_in | IP network (AS number) | | `ip.hosting_provider` | string | eq, neq, in, not_in | IP hosting provider | | `captcha.passed` | boolean | is | Captcha passed | | `duplicate.canonical_mailbox` | boolean | is | Mailbox already has an account | | `history.fraud` | boolean | is | Seen in fraud before | | `login.new_country` | boolean | is | Login from a new country | | `login.new_network` | boolean | is | Login from a new network | | `score` | number | eq, neq, gt, gte, lt, lte | Score (0-1) | | `velocity.ip_1h` | number | eq, neq, gt, gte, lt, lte | Events from the IP (1 h) | | `velocity.ip_24h` | number | eq, neq, gt, gte, lt, lte | Events from the IP (24 h) | | `velocity.subnet_1h` | number | eq, neq, gt, gte, lt, lte | Events from the subnet (1 h) | | `velocity.subnet_24h` | number | eq, neq, gt, gte, lt, lte | Events from the subnet (24 h) | | `velocity.email_domain_1h` | number | eq, neq, gt, gte, lt, lte | Events with the email domain (1 h) | | `velocity.email_domain_24h` | number | eq, neq, gt, gte, lt, lte | Events with the email domain (24 h) | | `velocity.mailbox_1h` | number | eq, neq, gt, gte, lt, lte | Events with the mailbox (1 h) | | `velocity.mailbox_24h` | number | eq, neq, gt, gte, lt, lte | Events with the mailbox (24 h) | | `velocity.user_1h` | number | eq, neq, gt, gte, lt, lte | Events of the user (1 h) | | `velocity.user_24h` | number | eq, neq, gt, gte, lt, lte | Events of the user (24 h) | # Settings (/docs/risk/settings) Each environment has its own risk settings, in the dashboard (**Risk → Settings**) or through the API. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- await coss.risk.settings.update({ review_threshold: 0.4, block_threshold: 0.9, disabled_reasons: ["email.role_account"], }); ``` ## Thresholds [#thresholds] A score at or above `review_threshold` is `review`, at or above `block_threshold` is `block` (defaults 0.5 and 0.85). Lists and rules decide before the thresholds. `review_threshold` can't be above `block_threshold`; setting them equal skips `review`. ## Disabled reasons [#disabled-reasons] Reasons you list in `disabled_reasons` never fire: they don't appear in assessments or count in the score. [Rules](/docs/risk/rules) still see the facts behind them. Decision reasons (`list.*`, `rule.matched`) can't be disabled. ## Captcha [#captcha] Pass the token of your captcha widget with assessments and COSS verifies it with the provider: `captcha.failed` when the provider refuses it. Set the provider and your **secret key** first; the secret is stored encrypted and never returned. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const turnstileToken: string; // ---cut--- await coss.risk.settings.update({ captcha: { provider: "turnstile", secret: "0x4AAAAAAA…", required: true }, }); const { data } = await coss.risk.assess({ event: "signup", user: { email: "ada@acme.com" }, captcha: { token: turnstileToken }, }); ``` - Providers: Cloudflare Turnstile (`turnstile`) and hCaptcha (`hcaptcha`). COSS sends them the token, your secret and the client's IP address. - With `required: true`, assessments without a token get `captcha.missing`. - Tokens are single-use: verify each one once. A retried assessment (same `Idempotency-Key`) returns the first result without verifying again. - If the provider can't be reached, the assessment goes on without the captcha and lists `captcha` in `degraded`. - `captcha: null` turns verification off and deletes the secret. # Testing (/docs/risk/testing) Sandbox (`coss_test_…`) keys assess like live ones, with their own counters, memory and lists, and these test inputs (documentation addresses and `.test` domains, which never belong to real users): | Input | Behaves as | | --------------------------- | --------------------------------------------------- | | IP `192.0.2.1` | A Tor exit node in Germany (`ip.tor`) | | IP `192.0.2.2` | An AWS server in the US (`ip.hosting_provider`) | | IP `192.0.2.3` | A VPN network in Romania (`ip.vpn_network`) | | Any other `192.0.2.x` | A home connection in the UK (no reason) | | Email `…@disposable.test` | A disposable domain (`email.disposable`) | | Email `…@undeliverable.test` | A domain that can't receive mail (`email.undeliverable`) | | Email `…@new.test` | A domain registered 2 days ago (`email.domain_new`) | | Any other `….test` domain | A domain that receives mail (no DNS lookup) | | Captcha token `test-pass` | A token the provider accepts | | Captcha token `test-fail` | A token the provider refuses (`captcha.failed`) | The captcha test tokens work without a captcha provider in the settings. Typos (`@gmail.con`), role accounts (`info@`), velocity, duplicates, login history, feedback, lists, rules and settings work as in live environments. ```ts twoslash import { Coss } from "coss"; // A sandbox key: coss_test_… const coss = new Coss(); // ---cut--- const { data } = await coss.risk.assess({ event: "signup", user: { email: "x@disposable.test" }, ip: "192.0.2.1", }); // data?.decision === "block": a disposable address over Tor ``` # Errors and retries (/docs/sdk/errors) ## The error object [#the-error-object] When a call fails, `error` is a plain `CossError` object (never an exception): | Field | Type | Description | | ------------ | ------------------------------------- | ------------------------------------------------------ | | `type` | `CossErrorType` | The category, see [Error types](#error-types) | | `code` | `string` | Machine-readable reason, e.g. `invalid_api_key` | | `message` | `string` | Human-readable explanation; may change | | `status` | `number \| null` | HTTP status, `null` when no response was received | | `request_id` | `string \| null` | The API's `X-Request-Id` | | `param` | `string`, optional | The request field or header the error relates to | | `issues` | `{ path: string; message: string }[]`, optional | Per-field validation problems | Include `request_id` when contacting support. `issues` lists per-field problems of a `validation_error`. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { error } = await coss.email.send({ from: "Acme ", to: "user@example.com", subject: "", text: "Hi!", }); if (error?.code === "validation_error") { for (const issue of error.issues ?? []) console.log(issue.path, issue.message); } ``` ## Error types [#error-types] | `type` | Status | Meaning | | ----------------------- | --------------- | -------------------------------------------------------------------- | | `invalid_request_error` | 400 / 413 / 422 | Malformed JSON, payload too large, or failed validation (`issues`) | | `authentication_error` | 401 | `missing_api_key` or `invalid_api_key` | | `permission_error` | 403 | The key lacks the permission (`insufficient_permissions`) | | `not_found_error` | 404 | No such resource in the key's environment | | `idempotency_error` | 409 / 422 | `request_in_progress`, or `idempotency_key_reused` with a different body | | `rate_limit_error` | 429 | Too many requests | | `billing_error` | 402 | `usage_limit_exceeded`, `billing_hold`, `plan_limit_reached` or `plan_feature_unavailable` (never retried; see [Plans and billing](/docs/billing)) | | `api_error` | 5xx, other | Server error, or a response that is not a COSS error (`unexpected_response`) | | `connection_error` | none | Network failure (`network_error`), or you aborted (`aborted`) | | `timeout_error` | none | An attempt exceeded `timeout` (`request_timeout`) | `connection_error` and `timeout_error` happen on the client, so their `status` and `request_id` are `null`. ## Automatic retries [#automatic-retries] The SDK retries up to `maxRetries` times (default 2) on connection errors, timeouts, and `409`, `429` and `5xx` responses. Other errors are returned immediately. - The wait grows exponentially (500 ms, 1 s, 2 s, … up to 8 s) with random jitter. - A `Retry-After` header is honored exactly; if it asks for more than 60 s, the error is returned instead. - Every `POST` carries an `Idempotency-Key` that is reused on each retry, so a retried send is never delivered twice. # Overview (/docs/sdk) `coss` is the official TypeScript SDK. It has zero dependencies, is fully typed and runs on Node.js 22+, Bun, Deno and Cloudflare Workers. ```package-install coss ``` The SDK is not published to npm yet. Until it is, use it from the COSS repository. ## The client [#the-client] One client covers every product. Create it once and reuse it. Each endpoint's page in the [API reference](/docs/api-reference) shows its SDK call in the **TypeScript** tab; these pages cover how the client itself behaves. ```ts twoslash import { Coss } from "coss"; const coss = new Coss({ apiKey: process.env.COSS_API_KEY }); // ^? ``` Hover any identifier in these examples to see its type. ## Options [#options] ```ts twoslash import { Coss } from "coss"; // ---cut--- const coss = new Coss({ apiKey: "coss_test_...", baseUrl: "https://api.coss.com", timeout: 30_000, maxRetries: 2, dangerouslyAllowBrowser: false, }); ``` | Option | Default | Description | | ------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | `apiKey` | `COSS_API_KEY` | A `coss_live_…` or `coss_test_…` key | | `baseUrl` | `COSS_BASE_URL`, then `https://api.coss.com` | The API origin | | `timeout` | `30000` | Per-attempt timeout in milliseconds | | `maxRetries` | `2` | Retries for network errors, timeouts, 409, 429 and 5xx | | `fetch` | global `fetch` | A custom `fetch` implementation | | `dangerouslyAllowBrowser` | `false` | Allow running where `document` exists (exposes your key) | The constructor throws only for programmer errors: a missing API key, or running in a browser without `dangerouslyAllowBrowser`. ## Results, not exceptions [#results-not-exceptions] Every method returns `{ data, error }`. API and network failures come back as `error`; they are never thrown. Checking `error` narrows `data`: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const result = await coss.email.get("email_01k7c8m4q2f3e9v6t5r1n0p8yz"); if (result.error) { console.error(result.error.message); } else { console.log(result.data.subject); // ^? } ``` ## Per-request options [#per-request-options] Methods take an optional last argument. Every method accepts an `AbortSignal`; `send` also takes an idempotency key. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); const params = { from: "a@acme.com", to: "b@example.com", subject: "Hi", text: "Hi" }; // ---cut--- const controller = new AbortController(); await coss.email.send(params, { idempotencyKey: "welcome-user-123", signal: controller.signal }); await coss.email.get("email_01k7c8m4q2f3e9v6t5r1n0p8yz", { signal: controller.signal }); ``` ## Runtimes [#runtimes] The package is ESM-only and uses nothing but `fetch`, `AbortSignal` and `crypto.randomUUID`. Environment fallbacks read `process.env` where it exists; on Workers or Deno without env access, pass `apiKey` (and `baseUrl`) explicitly. # Verifying webhooks (/docs/sdk/webhooks) `verifyWebhook(body, headers, secret)` checks a request's [Standard Webhooks](https://www.standardwebhooks.com) signature and age, and returns the parsed event. Pass the **raw** request body, exactly as received, and the endpoint's `whsec_…` signing secret (shown in the dashboard, or returned by `coss.webhookEndpoints.create` and `rollSecret`). While a [rolled secret](/docs/webhooks#rolling-the-secret) is in its grace period, requests verify with either the old or the new secret. ```ts twoslash title="app/webhooks/coss/route.ts" import { verifyWebhook, WebhookVerificationError } from "coss"; export async function POST(request: Request) { try { const event = await verifyWebhook( await request.text(), request.headers, process.env.COSS_WEBHOOK_SECRET!, ); if (event.type === "email.bounced") { console.log(event.data.recipient, event.data.bounce?.type); // ^? } return new Response("ok"); } catch (error) { if (error instanceof WebhookVerificationError) { return new Response("invalid signature", { status: 400 }); } throw error; } } ``` Unlike every other SDK function, `verifyWebhook` **throws**: a failed signature check must not be ignorable. `headers` can be a `Headers` object or a plain Node-style record. Requests older than 5 minutes (or from a clock more than 5 minutes off) are rejected. Change the window with `toleranceSeconds`: ```ts twoslash import { verifyWebhook } from "coss"; declare const body: string; declare const headers: Headers; // ---cut--- const event = await verifyWebhook(body, headers, process.env.COSS_WEBHOOK_SECRET!, { toleranceSeconds: 600, }); ``` See [Webhooks](/docs/webhooks) for event types, retries and ordering. # Webhooks (/docs/webhooks) Add webhook endpoints per environment in the dashboard (**Webhooks**) or through the API. Each endpoint subscribes to event types (all of them by default) and has its own `whsec_…` signing secret. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); // ---cut--- const { data: endpoint, error } = await coss.webhookEndpoints.create({ url: "https://example.com/webhooks/coss", event_types: ["email.delivered", "email.bounced"], }); // Store the secret now: it is only returned when the endpoint is created (or its secret rolled). const secret = endpoint?.secret; ``` ## Events [#events] | Type | Sent when | | ------------------------ | ------------------------------------------------------- | | `email.sent` | The provider accepted the email | | `email.delivered` | A recipient's mail server accepted it | | `email.delivery_delayed` | Delivery to a recipient is being retried | | `email.bounced` | A recipient's mail server rejected it | | `email.complained` | A recipient marked it as spam | | `email.failed` | The email could not be sent at all | | `email.suppressed` | A recipient was skipped because it is suppressed | | `email.received` | An email arrived at one of the environment's [receiving addresses](/docs/email/receiving) | | `risk.assessment.review` | A [risk assessment](/docs/risk) decided `review` | | `risk.assessment.reviewed` | Feedback resolved a `review` | ### Organization events [#organization-events] These concern the whole organization, so every **live** environment's endpoints receive them (sandbox endpoints don't). `data.organization_id` names the organization. | Type | Sent when | | --------------------------------- | --------------------------------------------------------------------------------------- | | `account.reputation_warning` | A reputation check (bounces, complaints, content) crossed its warning threshold | | `account.reputation_recovered` | The warning cleared | | `account.suspended` | Live sending was suspended | | `account.reinstated` | The suspension was lifted | | `billing.invoice_finalized` | An invoice was issued (`data.invoice`, without lines) | | `billing.invoice_paid` | An invoice was paid in full | | `billing.invoice_payment_failed` | Charging an invoice failed (`data.failure.message`); it is retried | | `billing.invoice_voided` | An open invoice was voided | | `billing.plan_changed` | The plan changed or a change was scheduled or undone (`change`, `plan`, `effective_at`) | | `billing.usage_threshold_reached` | Usage reached an alert threshold of the month's credit or Free's allowance (`percent`) | | `billing.hold_placed` | Live sending was paused for billing (`reason`: `delinquent`, `spend_limit` or `admin`) | | `billing.hold_lifted` | That pause ended | | `billing.credit_granted` | A credit was added (`data.credit`) | See [Plans and billing](/docs/billing#billing-api-and-events) for what they mean and how to read the rest of an organization's billing. The request body is a JSON event. Payloads describe the email (`email_id`, `from`, `to`, `subject`, `tags`, `category`) but never include its content, headers, cc or bcc; `email.received` events likewise only name the received email (`received_email_id`) and who sent it to whom. Events are rendered in the endpoint's [API version](/docs/api-reference/versioning#webhooks), named in `api_version`. ```json { "id": "evt_01k7c8m4q2f3e9v6t5r1n0p8yz", "object": "event", "type": "email.delivered", "api_version": "2026-10-10", "created_at": "2026-10-10T12:00:01.000Z", "data": { "email_id": "email_01k7c8m4q2f3e9v6t5r1n0p8yz", "from": "Acme ", "to": ["user@example.com"], "subject": "Welcome", "tags": [], "category": null, "sandbox": false, "recipient": "user@example.com", "occurred_at": "2026-10-10T12:00:00.000Z", "delivery": { "smtp_response": "250 2.0.0 OK" } } } ``` ## Verifying requests [#verifying-requests] Requests are signed with [Standard Webhooks](https://www.standardwebhooks.com) headers: `webhook-id`, `webhook-timestamp` and `webhook-signature`. Always verify them before trusting the body. With the SDK, use [`verifyWebhook`](/docs/sdk/webhooks). ## Rolling the secret [#rolling-the-secret] Roll an endpoint's secret in the dashboard (**Roll secret** on the endpoint) or through the API to replace it, for example on a schedule or after it may have leaked. For a grace period (a day by default, up to a week) every request carries two signatures, one per secret, in `webhook-signature`, so a receiver verifying with either one keeps accepting requests while you deploy the new secret. Choose `0` to stop the old secret at once. ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); declare const endpointId: string; // ---cut--- const { data: rolled } = await coss.webhookEndpoints.rollSecret(endpointId, { previous_secret_expires_in: 60 * 60, // the old secret keeps signing for an hour }); const newSecret = rolled?.secret; // deploy it before rolled?.previous_secret_expires_at ``` Rolling again during the grace period stops the older secret immediately. The SDK never retries a roll: if the response is lost, roll again and deploy that secret. ## Delivery and retries [#delivery-and-retries] - Respond with any `2xx` status within 10 seconds to acknowledge an event. - Failed deliveries are retried 7 times over about 28 hours (5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h). - Events can arrive out of order: sort by `data.occurred_at` (every event has it). - An event can arrive more than once: deduplicate by `id` (equal to the `webhook-id` header). ## Delivery logs [#delivery-logs] Every event sent to an endpoint is logged as a delivery, with each HTTP attempt (status code, duration, error and the first 1 KB of your response) and the payload as sent. Find them on the endpoint's page in the dashboard, or list them through the API — newest first, filtered by `status` or `event_id`: ```ts twoslash import { Coss } from "coss"; const coss = new Coss(); const endpointId = "whep_…"; // ---cut--- const { data: failed } = await coss.webhookEndpoints.deliveries.list(endpointId, { status: "failed", }); // After fixing your receiver, send a failed event again. for (const delivery of failed?.data ?? []) { await coss.webhookEndpoints.deliveries.retry(endpointId, delivery.id); } ``` A retry makes one more attempt within seconds, signed with the endpoint's current secret and sent to its current URL. Deleting an endpoint deletes its delivery log, and deliveries are deleted 30 days after the event (see [data retention](/docs/data-retention)). # Changelog (/docs/api-reference/changelog) Each [API version](/docs/api-reference/versioning) is listed with the breaking changes it introduced, newest first. Additive changes, such as new endpoints and fields, apply to every version and aren't listed here. # Errors (/docs/api-reference/errors) Errors use conventional HTTP status codes and always share one envelope: ```json { "error": { "type": "invalid_request_error", "code": "validation_error", "message": "Invalid request body: `subject`: Too small: expected string to have >=1 characters", "param": "subject", "issues": [{ "path": "subject", "message": "Too small: expected string to have >=1 characters" }], "request_id": "req_01k7c8m4q2f3e9v6t5r1n0p8yz" } } ``` | Field | Description | | ------------ | ------------------------------------------------------------- | | `type` | The category of error (below). Branch on this. | | `code` | A machine-readable reason, such as `invalid_api_key`. | | `message` | A human-readable explanation. It may change; don't parse it. | | `param` | The request field or header the error relates to, if any. | | `issues` | Every validation problem, for `validation_error`. | | `request_id` | Same as the `X-Request-Id` header. | ## Error types [#error-types] | `type` | Status | Meaning | | ----------------------- | --------------- | -------------------------------------------------------------------------- | | `invalid_request_error` | 400 / 413 / 415 / 422 | Malformed JSON, body too large, wrong content type, or failed validation | | `authentication_error` | 401 | The API key is missing, invalid, revoked or expired | | `permission_error` | 403 | The key lacks the permission, or sending is paused for the organization | | `not_found_error` | 404 | No such resource in the key's environment, or no such endpoint | | `idempotency_error` | 409 / 422 | A request with the key is in progress, or the key was reused with another body | | `rate_limit_error` | 429 | Too many requests | | `billing_error` | 402 | The plan or billing stands in the way: past the monthly allowance (`usage_limit_exceeded`), sending paused for an overdue invoice or the spend limit (`billing_hold`), or a plan limit (`plan_limit_reached`, `plan_feature_unavailable`). Don't retry; see [Plans and billing](/docs/billing) | | `api_error` | 5xx | Something went wrong on our side; retry with backoff | Each endpoint page lists the codes it can return. # Idempotency (/docs/api-reference/idempotency) `POST /v1/emails` accepts an `Idempotency-Key` header (1–256 characters). For 24 hours, a repeated request with the same key: - and the **same body** returns the email with its current status (including `queued`), with an `Idempotent-Replayed: true` header; - and a **different body** fails with `422 idempotency_key_reused`; - concurrent matching requests wait for the first transaction to commit and replay its email. A busy key can return `409 request_in_progress` with a `Retry-After` header. ```bash curl -X POST https://api.coss.com/v1/emails \ -H "Authorization: Bearer $COSS_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: welcome-user-123" \ -d '{"from":"Acme ","to":"user@example.com","subject":"Welcome","text":"Hi!"}' ``` `POST /v1/emails/batch` works the same way, with one key for the whole batch: a replay returns the same batch, with each email's current status. Batch keys and single-send keys are separate, so the same key can be used once for each. `POST /v1/hooks/applications/{application}/messages` ([Hooks](/docs/hooks)) works the same way: a replay returns the first message, and a reused key with another message body fails with `422 idempotency_key_reused`. Its keys are separate from email keys. Derive keys from your own data (`welcome-${userId}`) so that a retry after a crash reuses the same key. The [TypeScript SDK](/docs/sdk/errors#automatic-retries) sends a key on every `POST` and reuses it across its automatic retries. # Overview (/docs/api-reference) The COSS API is a JSON API. Every endpoint page in this reference shows the request, every response, and samples for the [TypeScript SDK](/docs/sdk), cURL, Python and Go. The machine-readable [OpenAPI 3.1 document](https://api.coss.com/openapi.json) describes the same endpoints. ## Base URL [#base-url] ``` https://api.coss.com ``` Paths start with `/v1/…`. Breaking changes ship as dated API versions, selected with the `Coss-Version` header: see [Versioning](/docs/api-reference/versioning). ## Authentication [#authentication] Send your API key as a bearer token: ```bash curl https://api.coss.com/v1/emails/email_01k7c8m4q2f3e9v6t5r1n0p8yz \ -H "Authorization: Bearer $COSS_API_KEY" ``` The key decides the environment: requests only see data from the key's own environment. See [Environments and API keys](/docs/environments). ## Conventions [#conventions] - Request and response bodies are JSON (`Content-Type: application/json`). Requests are limited to 5 MB, except `POST /v1/emails`, which accepts up to 40 MB (at most 25 MB of decoded attachments). - Field names are `snake_case`; timestamps are ISO 8601 strings in UTC. - IDs are prefixed with their type: `email_…`, `evt_…`. - Every object has an `object` field naming its type (`"email"`, `"email_event"`, `"list"`). - Lists are `{ "object": "list", "data": [...], "has_more": false }`. - Every response carries an `X-Request-Id` header (`req_…`). Include it when contacting support. # Versioning (/docs/api-reference/versioning) The API changes in two ways: - **Additive changes** ship to every version as soon as they are ready: new endpoints, new optional request fields, new response fields, new event types and new error codes. Write integrations that ignore fields they don't know. - **Breaking changes** (renaming or removing a field, changing a type or a default) ship as a new **API version**, named after the date it was released, such as `2026-10-10`. Your integration keeps the old behavior until you upgrade. The [changelog](/docs/api-reference/changelog) lists every version and what it changed. The path prefix `/v1` stays the same across versions. ## Choosing a version [#choosing-a-version] Send the `Coss-Version` header to pick the version of one request: ```bash curl https://api.coss.com/v1/domains \ -H "Authorization: Bearer $COSS_API_KEY" \ -H "Coss-Version: 2026-10-10" ``` Without the header, a request uses your **organization's version**. It is pinned to the latest version on your organization's first API request and only changes when you upgrade it in the dashboard (**Settings → API version**). An upgrade can't be undone, so test the new version first by sending its `Coss-Version` header. Every response says which version it was rendered in, in its `Coss-Version` header. An unknown version is rejected with `400 invalid_request_error` and the code `invalid_api_version`. Errors use the same [envelope](/docs/api-reference/errors) in every version. ## The TypeScript SDK [#the-typescript-sdk] The SDK sends `Coss-Version` with every request: the version its types describe, exported as `API_VERSION`. Upgrading the SDK is how you move to a new API version, and your organization's pinned version never affects it. ```ts twoslash import { API_VERSION, Coss } from "coss"; const coss = new Coss({ apiKey: "coss_test_…" }); console.log(API_VERSION); // ^? ``` ## Webhooks [#webhooks] Each webhook endpoint has its own `api_version`, and every event sent to it is rendered in that version and carries it as `api_version`: ```json { "id": "evt_01k7c8m4q2f3e9v6t5r1n0p8yz", "object": "event", "type": "email.delivered", "api_version": "2026-10-10", "created_at": "2026-10-10T12:00:03.000Z", "data": { "email_id": "email_01k7c8m4q2f3e9v6t5r1n0p8yz" } } ``` An endpoint created over the API gets the version of the request that created it (the `Coss-Version` header, the SDK's version or your organization's). One created in the dashboard gets your organization's version. Change it with `api_version` when you update the endpoint, after your receiver handles the new shape. The new version also applies to deliveries that are still being retried. # Send an email (/docs/api-reference/emails/send-email) Queues an email from the API key's environment and returns it with `status: "queued"`; it is handed to the delivery provider moments later. Follow it with `GET /v1/emails/{id}` or the `email.sent` / `email.failed` webhooks. Live (`coss_live_…`) keys must send from a verified domain of the organization. Sandbox (`coss_test_…`) keys accept any `from` and never deliver anything; the response has `sandbox: true`. Recipients on the environment's suppression list (for every email, or for the email's `category`) are skipped and get the status `suppressed`. With a future `scheduled_at` the email is stored with the status `scheduled` and sent at that time; the domain and the suppression list are checked again then. Attachments are base64-encoded; only metadata is kept after sending. Recipients count against the organization's send quota (per second and per day; sandbox environments have their own daily quota). With open or click tracking on (the environment's settings, or `tracking`), the HTML body is delivered with a tracking pixel and/or links rewritten to COSS redirects; the stored `html` stays as sent. Queues an email from the API key's environment and returns it with `status: "queued"`; it is handed to the delivery provider moments later. Follow it with `GET /v1/emails/{id}` or the `email.sent` / `email.failed` webhooks. Live (`coss_live_…`) keys must send from a verified domain of the organization. Sandbox (`coss_test_…`) keys accept any `from` and never deliver anything; the response has `sandbox: true`. Recipients on the environment's suppression list (for every email, or for the email's `category`) are skipped and get the status `suppressed`. With a future `scheduled_at` the email is stored with the status `scheduled` and sent at that time; the domain and the suppression list are checked again then. Attachments are base64-encoded; only metadata is kept after sending. Recipients count against the organization's send quota (per second and per day; sandbox environments have their own daily quota). With open or click tracking on (the environment's settings, or `tracking`), the HTML body is delivered with a tracking pixel and/or links rewritten to COSS redirects; the stored `html` stays as sent. # Send a batch of emails (/docs/api-reference/emails/send-email-batch) Queues up to 100 emails in one request. Each email is handled exactly as by `POST /v1/emails` (sender checks, suppressions, scheduling, attachments, tracking), but the batch is all or nothing: if any email is invalid, or any `from` is not a verified domain (live keys), nothing is stored and the error names the email (e.g. `emails.3.to`). One `Idempotency-Key` covers the whole batch. The emails' recipients are charged against the send quota together, so a batch with more recipients than the quota allows at once is refused (`quota_exceeded` without `Retry-After`): split it. The request body shares the 40 MB limit of a single send. Queues up to 100 emails in one request. Each email is handled exactly as by `POST /v1/emails` (sender checks, suppressions, scheduling, attachments, tracking), but the batch is all or nothing: if any email is invalid, or any `from` is not a verified domain (live keys), nothing is stored and the error names the email (e.g. `emails.3.to`). One `Idempotency-Key` covers the whole batch. The emails' recipients are charged against the send quota together, so a batch with more recipients than the quota allows at once is refused (`quota_exceeded` without `Retry-After`): split it. The request body shares the 40 MB limit of a single send. # Retrieve an email (/docs/api-reference/emails/get-email) Only emails sent from the API key's own environment can be retrieved. Only emails sent from the API key's own environment can be retrieved. # Cancel a scheduled email (/docs/api-reference/emails/cancel-email) Cancels an email sent with a future `scheduled_at` before it is sent. Canceling an already canceled email returns it unchanged. To send it at another time, reschedule it instead. Cancels an email sent with a future `scheduled_at` before it is sent. Canceling an already canceled email returns it unchanged. To send it at another time, reschedule it instead. # Reschedule a scheduled email (/docs/api-reference/emails/reschedule-email) Moves an email sent with a future `scheduled_at` to a new time, at most 30 days from now, and records an `email.rescheduled` event. A time that is not in the future sends it within seconds. Rescheduling to the time it already has returns it unchanged. The domain and the suppression list are still checked when it is sent. Moves an email sent with a future `scheduled_at` to a new time, at most 30 days from now, and records an `email.rescheduled` event. A time that is not in the future sends it within seconds. Rescheduling to the time it already has returns it unchanged. The domain and the suppression list are still checked when it is sent. # List an email's delivery events (/docs/api-reference/emails/list-email-events) Every delivery event of the email, oldest first: `email.scheduled`, `email.rescheduled`, `email.canceled`, `email.sent` and `email.failed` for the whole email, and `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained` and `email.suppressed` (skipped because of the suppression list) per recipient, plus `email.opened` and `email.clicked` when tracking is on (attributed to a recipient only when the email had a single one). Every delivery event of the email, oldest first: `email.scheduled`, `email.rescheduled`, `email.canceled`, `email.sent` and `email.failed` for the whole email, and `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained` and `email.suppressed` (skipped because of the suppression list) per recipient, plus `email.opened` and `email.clicked` when tracking is on (attributed to a recipient only when the email had a single one). # List an email's tracked links (/docs/api-reference/emails/list-email-links) The links COSS rewrote for click tracking, in the order they appear in the HTML, with their click counts. Empty unless the email was sent with click tracking. The links COSS rewrote for click tracking, in the order they appear in the HTML, with their click counts. Empty unless the email was sent with click tracking. # List sending domains (/docs/api-reference/domains/list-domains) Every domain of the organization, ordered by name. Domains belong to the organization, not to one environment, so every API key of the organization sees the same domains. Every domain of the organization, ordered by name. Domains belong to the organization, not to one environment, so every API key of the organization sees the same domains. # Add a sending domain (/docs/api-reference/domains/create-domain) Claims the domain for the organization and returns the DNS records to publish. Domains belong to the organization, not to one environment, so every API key of the organization sees the same domains. The records are checked repeatedly in the background and verification gives up after 72 hours; until the domain is `verified`, live keys cannot send from it. Claims the domain for the organization and returns the DNS records to publish. Domains belong to the organization, not to one environment, so every API key of the organization sees the same domains. The records are checked repeatedly in the background and verification gives up after 72 hours; until the domain is `verified`, live keys cannot send from it. # Retrieve a sending domain (/docs/api-reference/domains/get-domain) The domain with the result of the last DNS check, so you can show which records are still missing or mismatched. The domain with the result of the last DNS check, so you can show which records are still missing or mismatched. # Remove a sending domain (/docs/api-reference/domains/delete-domain) Removes the domain and its provider identity. Emails already sent are unaffected, but live keys can no longer send from it and the name becomes available again. Removes the domain and its provider identity. Emails already sent are unaffected, but live keys can no longer send from it and the name becomes available again. # Check a domain's DNS records now (/docs/api-reference/domains/verify-domain) Asks the delivery provider and DNS about the domain immediately instead of waiting for the next background check, and returns the updated domain. A `failed` domain starts verification over with fresh DKIM records, which have to be published again. Its tracking domain, if any, is checked too (and set up again if it `failed`). Asks the delivery provider and DNS about the domain immediately instead of waiting for the next background check, and returns the updated domain. A `failed` domain starts verification over with fresh DKIM records, which have to be published again. Its tracking domain, if any, is checked too (and set up again if it `failed`). # Add a custom tracking domain (/docs/api-reference/domains/create-tracking-domain) Sets up a subdomain of the domain (e.g. `links.acme.com`) to serve open and click tracking. Publish the CNAME record in `tracking.record`; COSS then issues its certificate and checks it in the background, giving up after 72 hours. Open pixels and click redirects of emails sent from the domain (or its subdomains without a domain of their own) use the tracking domain while it is `active`, and the default tracking host otherwise. A domain has at most one tracking domain: remove it to use another hostname. Sets up a subdomain of the domain (e.g. `links.acme.com`) to serve open and click tracking. Publish the CNAME record in `tracking.record`; COSS then issues its certificate and checks it in the background, giving up after 72 hours. Open pixels and click redirects of emails sent from the domain (or its subdomains without a domain of their own) use the tracking domain while it is `active`, and the default tracking host otherwise. A domain has at most one tracking domain: remove it to use another hostname. # Remove a custom tracking domain (/docs/api-reference/domains/delete-tracking-domain) Removes the domain's tracking domain. Later emails use the default tracking host, but links and open pixels in emails already sent through the tracking domain stop working. Removes the domain's tracking domain. Later emails use the default tracking host, but links and open pixels in emails already sent through the tracking domain stop working. # Receive email at a domain (/docs/api-reference/domains/create-receiving-domain) Starts receiving email at the domain itself or a subdomain (e.g. `inbound.acme.com`): email sent to any address at that hostname becomes a received email of the API key's environment (an `email.received` webhook, `GET /v1/received_emails`). Publish the MX record in `receiving.record`; COSS checks it in the background. Using the domain itself moves all of its mail to COSS. A domain has at most one receiving hostname: remove it to use another one. Starts receiving email at the domain itself or a subdomain (e.g. `inbound.acme.com`): email sent to any address at that hostname becomes a received email of the API key's environment (an `email.received` webhook, `GET /v1/received_emails`). Publish the MX record in `receiving.record`; COSS checks it in the background. Using the domain itself moves all of its mail to COSS. A domain has at most one receiving hostname: remove it to use another one. # Stop receiving email at a domain (/docs/api-reference/domains/delete-receiving-domain) Stops receiving at the domain's receiving hostname: later mail to it is dropped (remove the MX record too). Emails already received stay until their retention ends. Stops receiving at the domain's receiving hostname: later mail to it is dropped (remove the MX record too). Emails already received stay until their retention ends. # List webhook endpoints (/docs/api-reference/webhook-endpoints/list-webhook-endpoints) Every endpoint of the API key's environment, oldest first. Webhook endpoints belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox endpoints, and a `coss_live_…` key only production ones. Every endpoint of the API key's environment, oldest first. Webhook endpoints belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox endpoints, and a `coss_live_…` key only production ones. # Create a webhook endpoint (/docs/api-reference/webhook-endpoints/create-webhook-endpoint) Registers a URL that receives signed event notifications. Webhook endpoints belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox endpoints, and a `coss_live_…` key only production ones. The response carries the signing secret, which is returned only here and when it is rolled: store it, and verify every delivery with it (`verifyWebhook` in the SDK). Registers a URL that receives signed event notifications. Webhook endpoints belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox endpoints, and a `coss_live_…` key only production ones. The response carries the signing secret, which is returned only here and when it is rolled: store it, and verify every delivery with it (`verifyWebhook` in the SDK). # Retrieve a webhook endpoint (/docs/api-reference/webhook-endpoints/get-webhook-endpoint) The endpoint without its signing secret, which is only returned when created or rolled. The endpoint without its signing secret, which is only returned when created or rolled. # Update a webhook endpoint (/docs/api-reference/webhook-endpoints/update-webhook-endpoint) Changes the URL, description, subscribed event types, API version or `enabled` flag. Omitted fields keep their current value, and the signing secret stays the same (see Roll a signing secret), so receivers keep verifying deliveries. Disabling an endpoint stops deliveries without losing its configuration. Changes the URL, description, subscribed event types, API version or `enabled` flag. Omitted fields keep their current value, and the signing secret stays the same (see Roll a signing secret), so receivers keep verifying deliveries. Disabling an endpoint stops deliveries without losing its configuration. # Delete a webhook endpoint (/docs/api-reference/webhook-endpoints/delete-webhook-endpoint) Removes the endpoint and its delivery log. Deliveries already queued for it are dropped. To stop deliveries temporarily, set `enabled` to `false` instead. Removes the endpoint and its delivery log. Deliveries already queued for it are dropped. To stop deliveries temporarily, set `enabled` to `false` instead. # Roll a signing secret (/docs/api-reference/webhook-endpoints/roll-webhook-endpoint-secret) Replaces the endpoint's signing secret with a new one, returned in the response (the only time it is). Until `previous_secret_expires_at`, every delivery is signed with both secrets (`webhook-signature` lists two signatures), so receivers verifying with either one accept it: deploy the new secret, then let the old one expire. Rolling again in that time stops the older secret at once. Not idempotent: after a lost response, roll again. Replaces the endpoint's signing secret with a new one, returned in the response (the only time it is). Until `previous_secret_expires_at`, every delivery is signed with both secrets (`webhook-signature` lists two signatures), so receivers verifying with either one accept it: deploy the new secret, then let the old one expire. Rolling again in that time stops the older secret at once. Not idempotent: after a lost response, roll again. # Recover failed deliveries (/docs/api-reference/webhook-endpoints/recover-webhook-endpoint) Queues the endpoint's deliveries that `failed` since `since` for one more attempt each, oldest first, spread over the next minute, e.g. after fixing a receiver that was down. At most 1000 per call: while `has_more` is `true`, call again with the same `since`. The endpoint must be enabled. Queues the endpoint's deliveries that `failed` since `since` for one more attempt each, oldest first, spread over the next minute, e.g. after fixing a receiver that was down. At most 1000 per call: while `has_more` is `true`, call again with the same `since`. The endpoint must be enabled. # List an endpoint's deliveries (/docs/api-reference/webhook-endpoints/list-webhook-deliveries) The endpoint's delivery log, newest first: one delivery per event sent to it, with every HTTP attempt and the payload as sent. Pass the last `id` as `after` to get the next page while `has_more` is true. Deleting the endpoint deletes its log. The endpoint's delivery log, newest first: one delivery per event sent to it, with every HTTP attempt and the payload as sent. Pass the last `id` as `after` to get the next page while `has_more` is true. Deleting the endpoint deletes its log. # Retrieve a delivery (/docs/api-reference/webhook-endpoints/get-webhook-delivery) One delivery of the endpoint, with every HTTP attempt and the payload as sent. One delivery of the endpoint, with every HTTP attempt and the payload as sent. # Retry a delivery (/docs/api-reference/webhook-endpoints/retry-webhook-delivery) Queues a finished delivery (`succeeded` or `failed`) for one more attempt, made within seconds, signed with the endpoint's current secret and sent to its current URL. A delivery that is still `pending` is returned unchanged: it is already scheduled for `next_attempt_at` (automatic retries stop after 8 attempts). Queues a finished delivery (`succeeded` or `failed`) for one more attempt, made within seconds, signed with the endpoint's current secret and sent to its current URL. A delivery that is still `pending` is returned unchanged: it is already scheduled for `next_attempt_at` (automatic retries stop after 8 attempts). # List suppressed addresses (/docs/api-reference/suppressions/list-suppressions) The suppression list of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true. The suppression list of the API key's environment, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true. # Add an address to the suppression list (/docs/api-reference/suppressions/create-suppression) Suppresses an address in the API key's environment: later sends skip it, either for every email (`scope: "*"`, the default) or only for emails with the given `category` (e.g. to back an unsubscribe link). Adding an address that is already suppressed with the same scope returns the existing entry with status 200. Suppresses an address in the API key's environment: later sends skip it, either for every email (`scope: "*"`, the default) or only for emails with the given `category` (e.g. to back an unsubscribe link). Adding an address that is already suppressed with the same scope returns the existing entry with status 200. # Check whether an address is suppressed (/docs/api-reference/suppressions/check-suppression) Whether a send to `address` (with `category`, if given) would skip it, and the entries that match: those with scope `*` and, with a `category`, those for that category. Whether a send to `address` (with `category`, if given) would skip it, and the entries that match: those with scope `*` and, with a `category`, those for that category. # Import suppressions in bulk (/docs/api-reference/suppressions/import-suppressions) Adds up to 1000 entries at once, with reason `import`. Each entry is validated on its own: invalid ones are skipped and listed in `invalid`, entries already on the list are left unchanged and counted in `existing`. Importing again is safe. Adds up to 1000 entries at once, with reason `import`. Each entry is validated on its own: invalid ones are skipped and listed in `invalid`, entries already on the list are left unchanged and counted in `existing`. Importing again is safe. # Export the suppression list as CSV (/docs/api-reference/suppressions/export-suppressions) The whole suppression list of the API key's environment (optionally filtered), oldest first, as CSV with the columns `address,scope,reason,created_at,email_id,id`. The file can be imported again as is. Values a spreadsheet would run as a formula are prefixed with `'`. The whole suppression list of the API key's environment (optionally filtered), oldest first, as CSV with the columns `address,scope,reason,created_at,email_id,id`. The file can be imported again as is. Values a spreadsheet would run as a formula are prefixed with `'`. # Retrieve a suppression (/docs/api-reference/suppressions/get-suppression) # Remove a suppression (/docs/api-reference/suppressions/delete-suppression) Removes the entry, so later sends reach the address again (unless another entry still matches). Entries added for bounces and complaints can be removed too. Removes the entry, so later sends reach the address again (unless another entry still matches). Entries added for bounces and complaints can be removed too. # Retrieve tracking settings (/docs/api-reference/tracking/get-tracking-settings) The environment's open and click tracking default and its category rules. For each of `opens` and `clicks`, a send's own `tracking` wins, then the rule for its `category`, then the default, then off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. The environment's open and click tracking default and its category rules. For each of `opens` and `clicks`, a send's own `tracking` wins, then the rule for its `category`, then the default, then off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. # Update the tracking default (/docs/api-reference/tracking/update-tracking-default) Sets the environment default, which applies to emails without a rule for their category. Fields left out keep their value; if there is no default yet, it is created with them off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. Sets the environment default, which applies to emails without a rule for their category. Fields left out keep their value; if there is no default yet, it is created with them off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. # Remove the tracking default (/docs/api-reference/tracking/delete-tracking-default) Removes the environment default: emails without a rule for their category are then not tracked unless the send asks. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. Removes the environment default: emails without a rule for their category are then not tracked unless the send asks. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. # Update a category's tracking rule (/docs/api-reference/tracking/update-tracking-category) Sets the rule for emails sent with this `category`, which wins over the environment default, e.g. to track `marketing` but never `password-reset`. Fields left out keep their value; a new rule is created with them off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. Sets the rule for emails sent with this `category`, which wins over the environment default, e.g. to track `marketing` but never `password-reset`. Fields left out keep their value; a new rule is created with them off. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. # Remove a category's tracking rule (/docs/api-reference/tracking/delete-tracking-category) Removes the rule: emails of this category follow the environment default again. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. Removes the rule: emails of this category follow the environment default again. Settings belong to the API key's own environment: a `coss_test_…` key only ever changes sandbox settings. Emails keep the settings that applied when they were sent. # Retrieve the organization's sending reputation (/docs/api-reference/reputation/get-reputation) The organization's standing with the COSS reputation guard and the latest rates it measured over live environments. When a rate reaches its suspension threshold, live sending is suspended automatically (`account_suspended`) until COSS support reinstates the organization; sandbox keys keep working. Subscribe to the `account.*` webhook events to be notified of changes. The organization's standing with the COSS reputation guard and the latest rates it measured over live environments. When a rate reaches its suspension threshold, live sending is suspended automatically (`account_suspended`) until COSS support reinstates the organization; sandbox keys keep working. Subscribe to the `account.*` webhook events to be notified of changes. # List log lines (/docs/api-reference/logs/list-logs) The API key's environment log, newest first: every API request made with one of its keys (`source: api`), every email event (`email`) and every webhook delivery attempt (`webhook`). Filter by time range (an hour by default, at most 31 days), source, level, type, status, method, route, key, request, resource or free text. Pass the last `id` as `after` to get the next page while `has_more` is true. Reading logs is not itself logged. The API key's environment log, newest first: every API request made with one of its keys (`source: api`), every email event (`email`) and every webhook delivery attempt (`webhook`). Filter by time range (an hour by default, at most 31 days), source, level, type, status, method, route, key, request, resource or free text. Pass the last `id` as `after` to get the next page while `has_more` is true. Reading logs is not itself logged. # Retrieve a log line (/docs/api-reference/logs/get-log) # List audit events (/docs/api-reference/audit-log/list-audit-events) The organization's audit log, newest first: changes made by members in the dashboard, by API keys (from any environment) and by COSS support. The audit log is organization-wide, so every key of the organization reads the same events. Filter by action (an entry ending in `.` matches a whole resource, e.g. `domain.`), actor, target, environment or time. Pass the last `id` as `after` to get the next page while `has_more` is true. Reading the audit log is not itself logged. The organization's audit log, newest first: changes made by members in the dashboard, by API keys (from any environment) and by COSS support. The audit log is organization-wide, so every key of the organization reads the same events. Filter by action (an entry ending in `.` matches a whole resource, e.g. `domain.`), actor, target, environment or time. Pass the last `id` as `after` to get the next page while `has_more` is true. Reading the audit log is not itself logged. # Retrieve an audit event (/docs/api-reference/audit-log/get-audit-event) # Retrieve the organization's billing (/docs/api-reference/billing/get-billing) The organization's plan (and contract), the current billing month's usage and what it would add to the next invoice so far, its standing (`past_due`, billing holds), payment method, and what the plan includes. Organization-wide: every environment's key sees the same. Plan changes are made in the dashboard; subscribe to the `billing.*` webhook events to follow them. The organization's plan (and contract), the current billing month's usage and what it would add to the next invoice so far, its standing (`past_due`, billing holds), payment method, and what the plan includes. Organization-wide: every environment's key sees the same. Plan changes are made in the dashboard; subscribe to the `billing.*` webhook events to follow them. # List usage (/docs/api-reference/billing/list-billing-usage) Billable usage over a range (the current billing month by default), per meter, dimension and bucket (`hour`, `day` or `month`, UTC), oldest first; with `group_by=environment`, per environment too. Only live environments are billed, so only they appear. Usage counts when it becomes billable (an email when the provider accepts it) and is kept for 400 days. Billable usage over a range (the current billing month by default), per meter, dimension and bucket (`hour`, `day` or `month`, UTC), oldest first; with `group_by=environment`, per environment too. Only live environments are billed, so only they appear. Usage counts when it becomes billable (an email when the provider accepts it) and is kept for 400 days. # List invoices (/docs/api-reference/billing/list-invoices) The organization's invoices, newest first, without their lines. Pass the last `id` as `after` to get the next page while `has_more` is true. The organization's invoices, newest first, without their lines. Pass the last `id` as `after` to get the next page while `has_more` is true. # Retrieve an invoice (/docs/api-reference/billing/get-invoice) One invoice with its lines. One invoice with its lines. # List credits (/docs/api-reference/billing/list-credits) The organization's credits, newest first: each month's plan credit, prepaid commitments and credits from COSS support (in dollars, or free units of a product), with what's left. Only usable ones by default (`status=all` for every one). Pass the last `id` as `after` to get the next page while `has_more` is true. The organization's credits, newest first: each month's plan credit, prepaid commitments and credits from COSS support (in dollars, or free units of a product), with what's left. Only usable ones by default (`status=all` for every one). Pass the last `id` as `after` to get the next page while `has_more` is true. # List log drains (/docs/api-reference/log-drains/list-log-drains) Every drain of the API key's environment, oldest first. Log drains belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox drains, and a `coss_live_…` key only production ones. Every drain of the API key's environment, oldest first. Log drains belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox drains, and a `coss_live_…` key only production ones. # Create a log drain (/docs/api-reference/log-drains/create-log-drain) Streams the environment's logs to a URL: every line `GET /v1/logs` returns (API requests, email events, webhook attempts) and the environment's audit events, posted as signed NDJSON batches of up to 500 lines or 1 MB, usually within seconds. Log drains belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox drains, and a `coss_live_…` key only production ones. The response carries the signing secret, which is returned only here: store it, and verify every batch with it (`verifyLogDrainBatch` in the SDK). Streams the environment's logs to a URL: every line `GET /v1/logs` returns (API requests, email events, webhook attempts) and the environment's audit events, posted as signed NDJSON batches of up to 500 lines or 1 MB, usually within seconds. Log drains belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox drains, and a `coss_live_…` key only production ones. The response carries the signing secret, which is returned only here: store it, and verify every batch with it (`verifyLogDrainBatch` in the SDK). # Retrieve a log drain (/docs/api-reference/log-drains/get-log-drain) The drain without its signing secret, which is only returned when created. The drain without its signing secret, which is only returned when created. # Update a log drain (/docs/api-reference/log-drains/update-log-drain) Changes the URL, description, sources or `enabled` flag. Omitted fields keep their current value, and the signing secret stays the same. Disabling a drain drops the lines still waiting for it, and nothing is kept for it while it is disabled; enabling it again (also after COSS disabled it for failing) sends lines logged from then on. Changes the URL, description, sources or `enabled` flag. Omitted fields keep their current value, and the signing secret stays the same. Disabling a drain drops the lines still waiting for it, and nothing is kept for it while it is disabled; enabling it again (also after COSS disabled it for failing) sends lines logged from then on. # Delete a log drain (/docs/api-reference/log-drains/delete-log-drain) Removes the drain and its batch log. Lines still waiting for it are dropped. To stop it temporarily, set `enabled` to `false` instead. Removes the drain and its batch log. Lines still waiting for it are dropped. To stop it temporarily, set `enabled` to `false` instead. # List a drain's batches (/docs/api-reference/log-drains/list-log-drain-batches) The drain's delivery log, newest first: one batch per NDJSON request, with every HTTP attempt. A failing batch is retried with backoff for about a day (10 attempts); if every attempt fails, the drain is disabled. Pass the last `id` as `after` to get the next page while `has_more` is true. The drain's delivery log, newest first: one batch per NDJSON request, with every HTTP attempt. A failing batch is retried with backoff for about a day (10 attempts); if every attempt fails, the drain is disabled. Pass the last `id` as `after` to get the next page while `has_more` is true. # Retrieve a batch (/docs/api-reference/log-drains/get-log-drain-batch) One batch of the drain, with every HTTP attempt. One batch of the drain, with every HTTP attempt. # List event types (/docs/api-reference/hooks-event-types/list-hook-event-types) The environment's event types by name; archived ones only with `include_archived`. The environment's event types by name; archived ones only with `include_archived`. # Create an event type (/docs/api-reference/hooks-event-types/create-hook-event-type) Adds a type to the catalog your applications' users subscribe from (in the portal, or the `filter_types` of an endpoint), with a description, a JSON Schema and examples to show them. Messages can be sent with any type, registered or not. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. Adds a type to the catalog your applications' users subscribe from (in the portal, or the `filter_types` of an endpoint), with a description, a JSON Schema and examples to show them. Messages can be sent with any type, registered or not. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. # Retrieve an event type (/docs/api-reference/hooks-event-types/get-hook-event-type) An event type of the API key's environment, archived or not. An event type of the API key's environment, archived or not. # Update an event type (/docs/api-reference/hooks-event-types/update-hook-event-type) Changes its description, schema or examples, or archives or restores it. Changes its description, schema or examples, or archives or restores it. # Archive an event type (/docs/api-reference/hooks-event-types/archive-hook-event-type) Hides the type from the portal and from new subscriptions. Endpoints already subscribed keep receiving it. Restore it with `PATCH … { archived: false }`. Hides the type from the portal and from new subscriptions. Endpoints already subscribed keep receiving it. Restore it with `PATCH … { archived: false }`. # List applications (/docs/api-reference/hooks-applications/list-hook-applications) The environment's applications, newest first. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. The environment's applications, newest first. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. # Create an application (/docs/api-reference/hooks-applications/create-hook-application) Creates an application: one of your customers (or users), who gets their own endpoints and portal. Give it a `uid` (e.g. your customer's id) to address it by in every path. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. Creates an application: one of your customers (or users), who gets their own endpoints and portal. Give it a `uid` (e.g. your customer's id) to address it by in every path. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. # Retrieve an application (/docs/api-reference/hooks-applications/get-hook-application) An application of the API key's environment, by its id or uid. An application of the API key's environment, by its id or uid. # Update an application (/docs/api-reference/hooks-applications/update-hook-application) Changes the name, uid or metadata. Fields left out keep their value. Changes the name, uid or metadata. Fields left out keep their value. # Delete an application (/docs/api-reference/hooks-applications/delete-hook-application) Deletes the application with its endpoints and portal sessions; pending deliveries stop. Its messages are no longer reachable and are deleted with the rest of their day's data. Deletes the application with its endpoints and portal sessions; pending deliveries stop. Its messages are no longer reachable and are deleted with the rest of their day's data. # List endpoints (/docs/api-reference/hooks-endpoints/list-hook-endpoints) The application's endpoints, oldest first. Secrets are never included. The application's endpoints, oldest first. Secrets are never included. # Create an endpoint (/docs/api-reference/hooks-endpoints/create-hook-endpoint) Registers a URL of the application's user that receives its messages, each request signed (Standard Webhooks) with the endpoint's own secret, returned here. Usually your users add endpoints themselves in the portal. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. Registers a URL of the application's user that receives its messages, each request signed (Standard Webhooks) with the endpoint's own secret, returned here. Usually your users add endpoints themselves in the portal. Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. # Retrieve an endpoint (/docs/api-reference/hooks-endpoints/get-hook-endpoint) An endpoint of the application. An endpoint of the application. # Update an endpoint (/docs/api-reference/hooks-endpoints/update-hook-endpoint) Changes the URL, description, filter types, rate limit, metadata or `enabled`. Enabling it again clears a failure streak. The secret stays the same (see roll secret). Changes the URL, description, filter types, rate limit, metadata or `enabled`. Enabling it again clears a failure streak. The secret stays the same (see roll secret). # Delete an endpoint (/docs/api-reference/hooks-endpoints/delete-hook-endpoint) Deletes the endpoint; its pending deliveries fail. Its delivery log stays. Deletes the endpoint; its pending deliveries fail. Its delivery log stays. # Retrieve an endpoint's secret (/docs/api-reference/hooks-endpoints/get-hook-endpoint-secret) The endpoint's current signing secret, e.g. to show its user. Needs `hook:update`, like rolling it. The endpoint's current signing secret, e.g. to show its user. Needs `hook:update`, like rolling it. # Roll an endpoint's secret (/docs/api-reference/hooks-endpoints/roll-hook-endpoint-secret) Replaces the signing secret, returned here. Until `previous_secret_expires_at`, requests carry a signature with each secret, so the receiver can switch without rejecting any. Not idempotent. Replaces the signing secret, returned here. Until `previous_secret_expires_at`, requests carry a signature with each secret, so the receiver can switch without rejecting any. Not idempotent. # Recover failed deliveries (/docs/api-reference/hooks-endpoints/recover-hook-endpoint) Queues every delivery to the endpoint that failed, of messages sent since `since`, for one more attempt each, e.g. after its receiver was down. COSS works through them in the background (oldest first); follow `status` with the returned recovery. The endpoint must be enabled. Queues every delivery to the endpoint that failed, of messages sent since `since`, for one more attempt each, e.g. after its receiver was down. COSS works through them in the background (oldest first); follow `status` with the returned recovery. The endpoint must be enabled. # Retrieve a recovery (/docs/api-reference/hooks-endpoints/get-hook-endpoint-recovery) A recovery of the endpoint: `pending` while deliveries are still being requeued. A recovery of the endpoint: `pending` while deliveries are still being requeued. # Send a test message (/docs/api-reference/hooks-endpoints/send-hook-endpoint-test) Sends a message to this endpoint only (whatever its filter), with the given payload or the event type's first example. It counts against the send quota like any message. Sends a message to this endpoint only (whatever its filter), with the given payload or the event type's first example. It counts against the send quota like any message. # List an endpoint's deliveries (/docs/api-reference/hooks-endpoints/list-hook-endpoint-deliveries) The endpoint's deliveries, newest first. The endpoint's deliveries, newest first. # List messages (/docs/api-reference/hooks-messages/list-hook-messages) The application's messages, newest first (kept for 30 days). The application's messages, newest first (kept for 30 days). # Send a message (/docs/api-reference/hooks-messages/send-hook-message) Queues a message to the application: COSS delivers it to each of its enabled endpoints subscribed to `event_type` within seconds, signed with each endpoint's secret, and retries failures with backoff for about a day. Each request's `webhook-id` header is the message's id. Counts against the send quota once per endpoint it goes to (at least once). Live sends are refused while the organization is suspended (`account_suspended`). Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. Queues a message to the application: COSS delivers it to each of its enabled endpoints subscribed to `event_type` within seconds, signed with each endpoint's secret, and retries failures with backoff for about a day. Each request's `webhook-id` header is the message's id. Counts against the send quota once per endpoint it goes to (at least once). Live sends are refused while the organization is suspended (`account_suspended`). Hooks resources belong to the API key's own environment: a `coss_test_…` key only ever sees sandbox ones, a `coss_live_…` key only production ones. # Retrieve a message (/docs/api-reference/hooks-messages/get-hook-message) A message of the application, with its payload. A message of the application, with its payload. # List a message's deliveries (/docs/api-reference/hooks-messages/list-hook-message-deliveries) One delivery per endpoint the message went to. One delivery per endpoint the message went to. # Retrieve a delivery (/docs/api-reference/hooks-messages/get-hook-delivery) One message's delivery to one endpoint. One message's delivery to one endpoint. # List a delivery's attempts (/docs/api-reference/hooks-messages/list-hook-delivery-attempts) Every HTTP request of the delivery, oldest first, with response excerpts. Every HTTP request of the delivery, oldest first, with response excerpts. # Retry a delivery (/docs/api-reference/hooks-messages/retry-hook-delivery) Queues a finished delivery for one more attempt within seconds, to the endpoint's current URL and secret. A `pending` delivery is returned unchanged. Queues a finished delivery for one more attempt within seconds, to the endpoint's current URL and secret. A `pending` delivery is returned unchanged. # Create a portal session (/docs/api-reference/hooks-portal/create-hook-portal-session) Signs the application's user in to the portal, where they add endpoints, pick event types, see deliveries and retry them, without you building any UI. Create one each time the user opens it (from your backend: never expose the API key) and open or embed the returned `url`; embedding needs the site's origin in the environment's portal settings. Signs the application's user in to the portal, where they add endpoints, pick event types, see deliveries and retry them, without you building any UI. Create one each time the user opens it (from your backend: never expose the API key) and open or embed the returned `url`; embedding needs the site's origin in the environment's portal settings. # List received emails (/docs/api-reference/receiving/list-received-emails) Emails received by the API key's environment, newest first, without their bodies and headers (retrieve one for those). Pass the last `id` as `after` to get the next page while `has_more` is true. An `email.received` webhook announces each one as it arrives. Emails received by the API key's environment, newest first, without their bodies and headers (retrieve one for those). Pass the last `id` as `after` to get the next page while `has_more` is true. An `email.received` webhook announces each one as it arrives. # Retrieve the environment's inbound address (/docs/api-reference/receiving/get-inbound-address) The environment's own domain under COSS's inbound domain: email sent to any address at it (e.g. `support@`) is received by this environment, with no DNS setup. To receive at your own domain, add a receiving domain instead. The environment's own domain under COSS's inbound domain: email sent to any address at it (e.g. `support@`) is received by this environment, with no DNS setup. To receive at your own domain, add a receiving domain instead. # Retrieve a received email (/docs/api-reference/receiving/get-received-email) The received email with its headers, text and HTML bodies (each cut at 2 MB, see `truncated`) and attachment metadata. HTML is the sender's: sanitize it before showing it to anyone. The received email with its headers, text and HTML bodies (each cut at 2 MB, see `truncated`) and attachment metadata. HTML is the sender's: sanitize it before showing it to anyone. # Delete a received email (/docs/api-reference/receiving/delete-received-email) Deletes the received email with its raw message and attachments right away, instead of at the end of its retention period. Deletes the received email with its raw message and attachments right away, instead of at the end of its retention period. # Download a received email's raw message (/docs/api-reference/receiving/get-received-email-raw) The whole message as received (`message/rfc822`), for your own MIME parser or archive. The whole message as received (`message/rfc822`), for your own MIME parser or archive. # Download an attachment of a received email (/docs/api-reference/receiving/get-received-email-attachment) The attachment's bytes, always as `application/octet-stream` (its declared type is the attachment's `content_type`): treat them as untrusted. The attachment's bytes, always as `application/octet-stream` (its declared type is the attachment's `content_type`): treat them as untrusted. # List assessments (/docs/api-reference/risk/list-risk-assessments) The assessments of the API key's environment, newest first. `review_status=open` is the review queue. Pass the last `id` as `after` to get the next page while `has_more` is true. The assessments of the API key's environment, newest first. `review_status=open` is the review queue. Pass the last `id` as `after` to get the next page while `has_more` is true. # Assess an event (/docs/api-reference/risk/create-risk-assessment) Assesses a sign-up, login or other event in the API key's environment and returns a decision (`allow`, `review` or `block`) with a score and the reasons behind it. COSS combines email and IP signals with what it has seen in this environment (velocity, accounts sharing a mailbox, identifiers you reported) and your allow and block lists. Signals that are unavailable at the moment are skipped and listed in `degraded`. Call it from your server, never from the browser, and treat errors as `allow` (fail open) so an outage never locks your users out. `review` decisions also fire a `risk.assessment.review` webhook. Sandbox (`coss_test_…`) keys accept documented test addresses (`192.0.2.x`, `@disposable.test`, `@undeliverable.test`). Assesses a sign-up, login or other event in the API key's environment and returns a decision (`allow`, `review` or `block`) with a score and the reasons behind it. COSS combines email and IP signals with what it has seen in this environment (velocity, accounts sharing a mailbox, identifiers you reported) and your allow and block lists. Signals that are unavailable at the moment are skipped and listed in `degraded`. Call it from your server, never from the browser, and treat errors as `allow` (fail open) so an outage never locks your users out. `review` decisions also fire a `risk.assessment.review` webhook. Sandbox (`coss_test_…`) keys accept documented test addresses (`192.0.2.x`, `@disposable.test`, `@undeliverable.test`). # Retrieve an assessment (/docs/api-reference/risk/get-risk-assessment) The email address and IP address are removed 90 days after the assessment; the decision, reasons and signals stay for a year. The email address and IP address are removed 90 days after the assessment; the decision, reasons and signals stay for a year. # Report an assessment's outcome (/docs/api-reference/risk/create-risk-feedback) Tells COSS what the assessed user turned out to be. `fraud` and `abuse` make later assessments with the same mailbox, user, IP address or subnet flag it (`history.fraud_*`); `legitimate` takes back what this assessment reported. Resolves an open review. Sending the latest outcome again changes nothing. Tells COSS what the assessed user turned out to be. `fraud` and `abuse` make later assessments with the same mailbox, user, IP address or subnet flag it (`history.fraud_*`); `legitimate` takes back what this assessment reported. Resolves an open review. Sending the latest outcome again changes nothing. # List allow and block list entries (/docs/api-reference/risk/list-risk-list-entries) The environment's list entries, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true. The environment's list entries, newest first. Pass the last `id` as `after` to get the next page while `has_more` is true. # Add an allow or block list entry (/docs/api-reference/risk/create-risk-list-entry) Adds an email address (matched by mailbox), domain (with its subdomains), IP address or CIDR range, AS number, country or user id to the environment's allow or block list. An allow-list match makes the decision `allow` whatever else fired; a block-list match makes it `block`. Adding an entry that already exists returns it with status 200. Adds an email address (matched by mailbox), domain (with its subdomains), IP address or CIDR range, AS number, country or user id to the environment's allow or block list. An allow-list match makes the decision `allow` whatever else fired; a block-list match makes it `block`. Adding an entry that already exists returns it with status 200. # Retrieve a list entry (/docs/api-reference/risk/get-risk-list-entry) # Remove a list entry (/docs/api-reference/risk/delete-risk-list-entry) # Forget a user (/docs/api-reference/risk/forget-risk-user) Call this when you delete one of your users: COSS deletes what identifies them in the environment (their mailbox, fraud reports, and the user id, email address, IP address and user agent on their assessments). Decisions and reasons stay. Forgetting a user COSS doesn't know succeeds too. Call this when you delete one of your users: COSS deletes what identifies them in the environment (their mailbox, fraud reports, and the user id, email address, IP address and user agent on their assessments). Decisions and reasons stay. Forgetting a user COSS doesn't know succeeds too. # Retrieve the risk settings (/docs/api-reference/risk/get-risk-settings) The API key's environment's decision thresholds, disabled reasons and captcha provider (never its secret). The API key's environment's decision thresholds, disabled reasons and captcha provider (never its secret). # Update the risk settings (/docs/api-reference/risk/update-risk-settings) Changes the fields given. `review_threshold` must not exceed `block_threshold`. Disabled reasons never fire (rules still see the facts behind them). Setting a captcha provider makes assessments verify their `captcha.token` with it; `required` adds `captcha.missing` to assessments without one. Changes the fields given. `review_threshold` must not exceed `block_threshold`. Disabled reasons never fire (rules still see the facts behind them). Setting a captcha provider makes assessments verify their `captcha.token` with it; `required` adds `captcha.missing` to assessments without one. # List rules (/docs/api-reference/risk/list-risk-rules) Every rule of the API key's environment, in evaluation order. Every rule of the API key's environment, in evaluation order. # Create a rule (/docs/api-reference/risk/create-risk-rule) Rules decide after the allow and block lists and before the score thresholds: the first enabled rule (lowest `position`, then oldest) whose conditions match the event sets the decision to its `action`. Conditions test the fields listed in the docs; a field without a value (no IP, no email) never matches. At most 50 rules per environment. Rules decide after the allow and block lists and before the score thresholds: the first enabled rule (lowest `position`, then oldest) whose conditions match the event sets the decision to its `action`. Conditions test the fields listed in the docs; a field without a value (no IP, no email) never matches. At most 50 rules per environment. # Retrieve a rule (/docs/api-reference/risk/get-risk-rule) # Update a rule (/docs/api-reference/risk/update-risk-rule) Changes the fields given; `conditions` replaces the whole list. Changes the fields given; `conditions` replaces the whole list. # Delete a rule (/docs/api-reference/risk/delete-risk-rule)