> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Send an in-app notification

> Your backend telling one of your own users something inside your product: an export finished, an invoice failed, somebody was mentioned. The person reads it from `GET /v1/contact/notifications` with their own session.

**Transactional by construction.** There is no category field on this request and there never will be one: an in-app notification is exempt from marketing consent the way a receipt is, and the exemption is the shape of the endpoint rather than a rule it applies. Nothing anybody switches off in `PUT /v1/contact/notification-preferences` can suppress one of these.

Name the recipient with `contact_id` or with your own `external_id` — **one of the two**. Both is a `400`: a request naming two people is one we would have to guess about, and the guess surfaces weeks later as a notification that reached the wrong user. Either way the key's environment scopes it, so a live `external_id` asked with a test key answers `404`.

**Say which team it is about** with `customer_id` or `customer_external_id` when the message belongs to one. Absent, it is the person's own and appears inside every team of theirs.

Send an `Idempotency-Key` if this call is retried by a client, a queue or a deploy — the retry replays the first response instead of telling somebody the same thing twice.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/notifications
openapi: 3.1.0
info:
  title: UserKit API
  version: 1.0.0
  description: >-
    The HTTP surface of UserKit.


    Two planes share one API. The **staff plane** is what a human uses in the
    panel: users, organizations, roles, members, keys. The **customer plane** is
    what a developer's own product uses: contacts, identities, the federated
    boot and the two proofs of an address.


    Every error answers the same envelope — `{"error": {"code", "message"}}`.
    The `code` is a stable contract to branch on; the `message` is for a person
    and may change.
servers:
  - url: '{baseUrl}'
    description: The API host.
    variables:
      baseUrl:
        default: https://api.userkit.dev
        description: Base URL of the API, no trailing slash.
security:
  - sessionToken: []
tags:
  - name: Gamification
    description: >-
      Points, levels and badges: the acts your product wants repeated, written
      down as rules, and what each of your own users has accumulated by doing
      them. A rule pays when the fact it names lands — there is no endpoint your
      users can call that moves a balance, and there is not going to be one.
  - name: Authentication
    description: >-
      Public sign-up, sign-in, two-factor and password recovery for staff
      accounts.
  - name: Session
    description: >-
      The active session: who the caller is, which organization they are in, and
      signing out.
  - name: Account
    description: The caller's own account — profile, password, sessions, avatar.
  - name: Two-factor
    description: >-
      TOTP setup, activation and recovery codes. Returns 501 when two-factor is
      unavailable on the server.
  - name: Organizations
    description: The organizations a user belongs to, and the active one.
  - name: Environments
    description: >-
      The live and test environments seeded with every organization, and their
      identity settings.
  - name: Members
    description: Memberships and invitations.
  - name: Roles
    description: Roles and the permission catalogue they draw from.
  - name: Audit log
    description: >-
      What staff did inside an organization. Append-only, and read behind its
      own permission.
  - name: API keys
    description: Secret keys (`uk_sk_…`) and publishable keys (`uk_pk_…`).
  - name: Contacts (staff session)
    description: >-
      The staff view of the customer plane, opened by a staff session. Reads
      take `?environment=` as an explicit view parameter.
  - name: Contacts (API key)
    description: >-
      The machine surface, authenticated by an API key. The environment is the
      key's environment and cannot be named by the caller.
  - name: Customer plane
    description: >-
      Called from the developer's own pages with a publishable key: boot, the
      two proofs of an address (magic link, email code), and the contact's own
      session.
  - name: Customer teams
    description: >-
      A customer is a team. Its roster, its invitations and the roles held
      inside it, administered by the contact's own session. `owner` and `member`
      always exist; the rest of the vocabulary is the organization's own — see
      Customer roles. The active customer travels in `X-Customer-Id`.
  - name: Customer roles
    description: >-
      The vocabulary of roles inside your customers, defined per environment. A
      customer used to be two shapes — the person who administers it and
      everybody else — which is what `owner` and `member` are, and they are
      still seeded into every environment and cannot be deleted. Beyond them you
      define your own: a school has a director, a secretary, a teacher and a
      student, and none of that fits a boolean.


      A role carries permissions from **two vocabularies in one list**.
      Permissions beginning with `$` are UserKit's own and are the only ones we
      evaluate — `$team.manage` administers the roster, `$billing.manage` the
      money, `$keys.manage` the team's API keys. Everything else is yours: we
      store it, we hand it back on the contact's session and in the
      `customer_permissions` claim of their JWT, and we never read it. That is
      the point — your backend gates `grades:write` offline, and adding a role
      never means editing your gate.


      Reading is `customers:read`, writing `customers:write`.
  - name: Webhooks
    description: >-
      Outbound webhooks: endpoints, the published event catalogue, the delivery
      log, replay and test sends. Never gated by a plan — webhooks are a
      developer primitive.
  - name: Catalogue
    description: >-
      The plans, prices and features **you** sell to your own customers. Per
      environment, a price per currency, and `recurring` or `one_time`. Distinct
      from the plan you are on with UserKit, which is `GET
      /v1/organization/entitlements`.
  - name: Subscriptions
    description: >-
      What one of **your** customers is paying you, mirrored from the gateway
      that charges them. The gateway is the truth about money — it holds the
      schedule, runs the retries and decides what a proration is worth — so
      these routes ask it to change something and answer with what it then said.
      `provider_synced_at` is how stale the copy admits to being.
  - name: Provider webhooks
    description: >-
      Where a payment provider delivers to. Not a surface you call — it is a URL
      you paste into the provider's dashboard, which is why it sits outside
      `/v1`: a version bump must never mean editing a setting in somebody else's
      product. Signed with the secret of the connection named in the path, and
      safe to retry.
  - name: Entitlements
    description: >-
      What one of **your** customers may do, resolved: the plan their
      subscription carries, plus the overrides you promised them on top. This is
      the read your own gate calls, so it is cached and answers in one round
      trip. It is a different question from `GET /v1/organization/entitlements`,
      which is the plan **you** are on with UserKit — two catalogues, same word,
      different money.
  - name: Credits
    description: >-
      How much of a `credit` feature one of **your** customers still holds. The
      balance is an append-only ledger folded on demand — grants arrive from
      paid invoices, usage spends against it (`POST /v1/usage`, fail closed),
      unused credits expire oldest-first, and a support correction is a new
      signed line rather than an edit. Whether a balance may go below zero, and
      how far, is the plan's `overdraft_limit`.
  - name: Customer API keys
    description: >-
      Keys **your customers** issue (`uk_ck_live_…` / `uk_ck_test_…`) so your
      own metered surface can be called per key. An owner of a customer team
      mints them from their session; your backend treats the string as data and
      resolves it with `POST /v1/customer-keys/verify` (or passes it as
      `customer_key` to `POST /v1/usage`). A customer key never authenticates
      against this API.
  - name: Customer webhooks
    description: >-
      Webhook endpoints **your customers** register from the portal, hearing the
      events *you* tracked about their team — `POST /v1/track` lines carrying
      their `customer_id`, delivered under your own event names. Administered
      under `$webhooks.manage` (the seeded `owner` role holds it), signed and
      retried exactly like your own endpoints, and gated by your plan:
      registering answers `webhooks_not_available` on a plan without the
      capability, and a downgrade stops deliveries without deleting anything.
  - name: Pricing
    description: >-
      The public read of what you sell: live plans and prices, addressed by
      publishable key, cacheable like JWKS. What a `<PricingTable/>` renders,
      and the offer the customer's own checkout sells from.
  - name: Customer billing
    description: >-
      The customer's **own** view of what their team pays, from their session:
      the subscription, the invoices, and the self-serve acts — checkout, plan
      change, cancel, resume, the gateway's portal. Reads are for every member;
      the money acts are owner-only, because spending the team's money is
      administering the team.
  - name: Support
    description: >-
      The support inbox. A conversation is opened by **your customer** — from
      the chat widget, or by an inbound email — and staff receive it: there is
      deliberately no staff-side create. Conversations live in an environment
      (their subject is a contact); tags and saved replies are the
      organization's own vocabulary and take no environment. `support:read` and
      `support:write` gate the staff surface, and every seeded role holds both —
      the support agent is the archetypal member seat.
  - name: Help center
    description: >-
      The articles you publish so a conversation does not have to happen.
      Organization-scoped — the product does not differ between live and test
      the way its contacts do — and versioned append-only: saving creates the
      next version, publishing points the public page at one of them, and
      restoring is publishing an old one. The editor rides the support
      permissions; the published side is public, addressed by your publishable
      key, and cacheable.
  - name: Segments
    description: >-
      Audiences, defined declaratively over contact attributes, plan, events and
      activity. One definition compiles into two evaluators that must agree — a
      query over the environment and an in-process match for one contact — so
      the count on a screen and the decision made about one person come from the
      same sentence. Reading is `customers:read` (a segment is a filtered list
      of contacts); writing is `segments:manage`, because one definition decides
      who is mailed, surveyed and shown an unreleased feature at once.
  - name: Feature flags
    description: >-
      Release switches the tenant's own product reads. A flag is aimed at a
      **segment** and never at a filter of its own, so the audience a rollout
      uses is the audience a survey and a changelog post can use. Two reads
      serve it: `POST /v1/boot` decides every flag for the person in the round
      trip the session already cost, and `GET /v1/flags/{publishable_key}` is a
      public, cacheable document that carries only the switch — polled on a
      fifteen-second clock and applied over whatever the page booted with. Staff
      CRUD is `flags:manage`.
  - name: Onboarding
    description: >-
      Checklists the widget renders for your own users, and the funnel behind
      them. A step is satisfied by a **fact having happened** or by an
      **entitlement being held** — there is no endpoint that marks one done, by
      construction, because a checklist whose completion is a button is a
      measure of who pressed the button. Event steps are written by a bus
      consumer and are what the funnel counts; entitlement steps are decided on
      the read against the team the request names, and nothing is stored for
      them. Staff CRUD and the funnel are `engagement:manage`, the permission
      the changelog and surveys share.
  - name: Surveys
    description: >-
      NPS and one-question surveys, asked in-app. A survey is aimed at a
      **segment** or at everybody, fires on a trigger (a fact you named,
      entering that audience, or N days after somebody signed up) and is held
      back by a **per-contact cooldown** — the window is per person rather than
      per survey, which is what makes "nobody is surveyed twice a week" true
      across every survey you run. Delivery is in-app: `GET
      /v1/contact/surveys/pending` is what the widget reads and `POST
      /v1/contact/surveys/{id}/response` is the answer, which announces
      `survey.response_recorded` to your webhooks. Staff CRUD and the results
      are `engagement:manage`, the permission onboarding and the changelog
      share.
  - name: Changelog
    description: >-
      What you shipped, said once. A post is written as a draft, aimed at a
      **segment** or at everybody, and published by an act of its own —
      publishing is what puts it in a cacheable public document and announces
      `changelog_post.published` to your webhooks, so it is a call you make on
      purpose rather than a field on an edit. Two reads serve it: `GET
      /v1/changelog/{publishable_key}` is the public release page and carries
      the posts aimed at nobody in particular; `GET /v1/contact/changelog` is
      the same list for one signed-in person, with the targeting honoured and
      their read state on it. Staff CRUD is `engagement:manage`, the permission
      onboarding and surveys share.
  - name: Banners
    description: >-
      Messages you put on your own users' screens — a strip at the top of the
      dashboard, a notice in the billing settings, a picture you designed. A
      banner names a **slot**, which is a place your own developer left room for
      with `<Banner slot="dashboard-top" />`; nothing is injected into your
      layout, because the only place a third party may draw is one the page
      owner declared. It is aimed at a **segment** or at everybody, bounded by
      an optional window, and only **one banner per slot** is ever returned —
      highest priority wins, decided in the database so every client gets the
      same answer. Two kinds: `text` (title, body, icon, tone, button), drawn by
      the widget in your palette, and `image`, a picture you made and optionally
      a link. `GET /v1/contact/banners` is what the widget reads and `POST
      /v1/contact/banners/{id}/dismiss` is the close button. Staff CRUD is
      `engagement:manage`, the permission onboarding, the changelog and surveys
      share.
  - name: Legal
    description: >-
      The terms your own customers agree to — terms of service, a privacy
      policy, a code of conduct — and the record of who agreed to which words. A
      **document** is a name, an address (`slug`) and a rule about whether
      agreeing is required; a **version** is the text, and it is frozen the
      moment it is published, because an acceptance that pointed at an editable
      body would prove nothing. Publishing may be dated ahead, which is what a
      thirty-day notice is: the version before it stays in force until then. A
      new version asks everybody again only when it says `requires_reacceptance`
      — a corrected typo does not, a new arbitration clause does. `GET
      /v1/legal/{publishable_key}` is public and cacheable, because a sign-up
      form has to render the link before there is any session. `required`
      refuses nothing — there is no sign-up door of UserKit's for it to refuse
      at — and is reported as `blocking` wherever outstanding agreements are
      read; what an outstanding agreement does is your product's decision. It
      never refuses an ordinary API call, and `POST /v1/contact/token` in
      particular — publishing new terms must mean "everybody is asked", never
      "everybody is logged out". Staff CRUD is `legal:manage`.
  - name: Feedback
    description: >-
      A feedback board and a public roadmap. Your users ask for things, vote on
      each other's requests and read what you answered; you triage, merge the
      duplicates and move a post to `planned`, `shipped` or `declined`. The part
      a separate feedback tool cannot do is the ranking: `GET
      /v1/organization/feedback?currency=BRL` weights every post by the
      **revenue of the distinct customers behind its voters**, so a team of five
      is worth its revenue once rather than five votes. That number exists on
      the staff read and nowhere else — the public board says how many people
      asked, never who pays. Reading is `feedback:read` (owner, admin **and**
      member, because triage is support work); answering is `feedback:write`.
  - name: Notifications
    description: >-
      Consent for the mail this platform sends your own users, and the
      unsubscribe link that changes it.


      Three categories — `marketing`, `product_news` and `surveys` — and every
      one of them is something a person can switch off. **Transactional mail is
      not one of them, and there is no value that names it.** A receipt, a
      dunning notice, a verification link and a password reset keep arriving
      after somebody unsubscribes from everything, because the absence of the
      category is what guarantees no screen and no bulk update can produce a
      person who cannot reset their own password.


      Consent is honoured at the **send funnel** rather than by whoever composes
      the message, in the same statement that records the delivery — so a
      message this person switched off is a message that is never composed, not
      one that is filtered afterwards. Every non-transactional message carries
      the unsubscribe link in its footer and the `List-Unsubscribe` /
      `List-Unsubscribe-Post` header pair on its envelope; no transactional
      message carries either.
  - name: Messaging
    description: >-
      The message templates outbound campaigns send: a subject, a body, a
      channel and the **category** the recipient can switch off.


      The subject and the body are Go `text/template`, compiled **when you
      save** — a template naming a field the message context does not carry is
      refused with the field named, never stored to fail per recipient in a job
      at three in the morning. The context is closed on purpose: `Contact`,
      `Customer`, `Plan` and `Entitlements`, and nothing else. Whatever a
      template can reach is reachable by whoever can write one.


      The category lives on the template rather than on whatever sends it,
      because a category is a statement about what a message *says*, and the
      template is the only object here that knows what it says. A campaign
      inherits it. `messaging:manage` covers the whole surface, reads included —
      an unsent price change is not something a wider permission should be able
      to read.
  - name: Campaigns
    description: >-
      The object that reaches out: an audience, a trigger, a sequence and a cap.


      The audience is always a **segment** — there is no filter of a campaign's
      own, because an audience defined twice can be previewed one way and mailed
      another. The words are a **message template**, and the campaign inherits
      the template's category rather than choosing one. The trigger is one of
      four: a one-shot to whoever is in the audience now, a fact of a named
      type, entering the audience, or N days from a timestamp on the contact.


      Two limits hold at once and they are different promises.
      `frequency_cap_days` is how long **this** campaign waits before reaching
      the same person again; a 24-hour cooldown across **every** campaign of the
      environment sits behind it, so somebody who trips three rules in one hour
      receives one message. Neither applies to the later steps of a sequence
      already begun — a cooldown that broke a sequence in half would be a setup
      that works in test and truncates in production.


      A contact who has switched the template's category off is recorded as
      **suppressed with the reason**, which is what lets support answer "why
      didn't they get it". Writing a campaign is free; **arming** one is what a
      plan gates.
  - name: In-app notifications
    description: >-
      The message that lands inside your product rather than in an inbox: "your
      export is ready", "the invoice failed", "someone mentioned you".


      Your backend posts one over `uk_sk_…` and the person reads it from their
      own session. It is **transactional by construction** — the request carries
      no category and there is no field that could carry one, so an in-app
      notification is not something anybody can unsubscribe from, in the same
      way a receipt is not. That is not a policy this endpoint applies; it is
      the only value the write can produce.


      The write inherits the machine surface's two promises by living on it: an
      `Idempotency-Key` replays the first response rather than telling somebody
      the same thing twice, and the per-key rate limit counts the call. The
      recipient is named by `contact_id` or by your own `external_id` — one of
      the two, never both, because a request naming two people is a request we
      would have to guess about.
  - name: MCP
    description: >-
      One endpoint an AI agent's client connects to, so "how many people signed
      up this week" is answered where the question is asked. Authenticated with
      an API key, which is also what fixes the environment — no tool takes one
      as an argument. Every tool is a READ: nothing here creates, changes or
      deletes anything, and nothing answers with a credential or with the staff
      audit log. Outside `/v1` because the URL lives in a configuration file
      rather than in your code.
  - name: Analytics
    description: >-
      The staff read of what happened inside your product: the event explorer,
      daily counts, saved queries and funnels. Everything here sits behind
      `analytics:read` — owner and admin by default, because Phase 4's
      dashboards put revenue on the same screens as event counts. The write path
      is `POST /v1/events` and the platform's own auth facts; no staff session
      reaches it.
  - name: Alerts
    description: >-
      Where your organization's bad news gets delivered: Slack, Discord or
      email. Everything else here is pull — you open a screen and the number is
      there — and this is the push half, for the five facts nobody opens a
      screen to discover: the contact meter at 80% and 100%, an environment out
      of its email allowance, a webhook endpoint turned off after sustained
      failure, a payment gateway that stopped answering, and a customer's
      payment being declined.


      The five are **fixed**. There is no rule to configure and no threshold to
      set — what fires is decided by the code that publishes each fact, and a
      second place to re-decide it would be a place that disagrees. What you
      configure is the destination.


      The destination is stored encrypted and is never read back: a Slack
      incoming webhook is a credential, and whoever holds it writes into that
      channel. Responses carry `destination_hint` — the host for a chat hook,
      the address for an email. Gated by `alerts:manage`, which only `owner`
      holds by default.
  - name: Referrals
    description: >-
      Your own users bringing you other users, and what you owe them for it. A
      code carries its terms (a share in basis points, or a flat bounty),
      `?ref=CODE` is captured by the SDK as first-touch attribution and recorded
      at the referred person's first identification, and a paid invoice appends
      a commission line. First touch is immutable — whoever referred somebody
      referred them, and a later link cannot take the commission — and a refund
      appends a negative line rather than erasing the positive one. Payouts are
      manual: this surface says what is owed and nothing here transfers money.
paths:
  /v1/notifications:
    post:
      tags:
        - In-app notifications
      summary: Send an in-app notification
      description: >-
        Your backend telling one of your own users something inside your
        product: an export finished, an invoice failed, somebody was mentioned.
        The person reads it from `GET /v1/contact/notifications` with their own
        session.


        **Transactional by construction.** There is no category field on this
        request and there never will be one: an in-app notification is exempt
        from marketing consent the way a receipt is, and the exemption is the
        shape of the endpoint rather than a rule it applies. Nothing anybody
        switches off in `PUT /v1/contact/notification-preferences` can suppress
        one of these.


        Name the recipient with `contact_id` or with your own `external_id` —
        **one of the two**. Both is a `400`: a request naming two people is one
        we would have to guess about, and the guess surfaces weeks later as a
        notification that reached the wrong user. Either way the key's
        environment scopes it, so a live `external_id` asked with a test key
        answers `404`.


        **Say which team it is about** with `customer_id` or
        `customer_external_id` when the message belongs to one. Absent, it is
        the person's own and appears inside every team of theirs.


        Send an `Idempotency-Key` if this call is retried by a client, a queue
        or a deploy — the retry replays the first response instead of telling
        somebody the same thing twice.
      operationId: createNotification
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - template
              properties:
                contact_id:
                  type: string
                  format: uuid
                  description: >-
                    Whose notification this is. Exactly one of `contact_id` or
                    `external_id`.
                external_id:
                  type: string
                  description: The same person, named by your own primary key.
                customer_id:
                  type: string
                  format: uuid
                  description: >-
                    Which of your teams this message is **about**, named with
                    our id. Optional, and at most one of `customer_id` /
                    `customer_external_id`.


                    Absent means the message is the person's own, and their feed
                    shows it inside every team of theirs — the right answer for
                    "sua senha foi alterada" and for a product with no teams at
                    all. Naming one scopes it: a person in two teams reads "a
                    fatura da Acme falhou" only while acting inside Acme.


                    Membership is not checked — your key is the authority on
                    which of your teams a message belongs to, exactly as it
                    already is on who the recipient is. The environment is
                    checked, because that is never a caller's to assert.
                customer_external_id:
                  type: string
                  description: >-
                    The same team, named by your own primary key — the id you
                    mirror it with. Both halves in one request is a `400
                    ambiguous_customer`, and a team that does not resolve in the
                    key's environment is a `400 unknown_customer`: a caller who
                    said which team meant it, and storing the message without
                    one would file it under "the person's own", where every team
                    of theirs would show it forever.
                template:
                  type: string
                  description: >-
                    **Required.** The `key` of the template to send.


                    The words live in your Userkit templates — one row per
                    channel under the same key — and this call names which
                    message and supplies its values. That is the point of the
                    arrangement: the sentence your customer reads sits where the
                    people responsible for it can read, translate and fix it,
                    instead of inside a deploy.


                    The template must be `transactional`. This endpoint sends
                    receipts, and a category somebody can switch off belongs in
                    a campaign — a `product_news` template here would be
                    marketing that ignores an unsubscribe.
                  example: fatura_vencendo
                data:
                  type: object
                  additionalProperties:
                    oneOf:
                      - type: string
                      - type: array
                        items:
                          type: object
                          additionalProperties:
                            type: string
                        description: The rows of a declared list.
                  description: >-
                    The values for the template's declared variables.


                    Every variable the template declares has to be here. A
                    missing one is a `400 missing_template_data` naming it —
                    never a body with a hole where the invoice number was. Extra
                    keys are ignored, so one payload can serve templates that
                    read different subsets of it.


                    A value is a string, or — for a declared list — an array of
                    rows whose values are strings. Strings and deliberately so:
                    `1.4e+07` in an invoice is what happens when a platform
                    formats somebody else's number, and only you know how your
                    currency and your dates are supposed to read.


                    A row missing a declared column is a `400` naming it once
                    (`membros[0].pontos`), not once per row.
                  example:
                    invoice_id: inv_5512
                    valor: R$ 149,00
                    vencimento: 05/09
                url:
                  type: string
                  maxLength: 2048
                  description: >-
                    Optional. Overrides the template's own destination for this
                    one message — which is what an invoice link that differs per
                    recipient needs.


                    Two shapes are accepted: a path rooted at `/`
                    (`/invoices/inv_123`), which is the common case and cannot
                    name a host, and an absolute `http`/`https` URL
                    (`https://app.example.com/invoices/inv_123`), for a product
                    that spans more than one domain. Anything else is a `400`,
                    including the protocol-relative `//host/path` — it reads as
                    a path and is an absolute URL to another host.


                    Omit it for a notification that is an announcement rather
                    than an errand; most are.
                channels:
                  type: array
                  items:
                    type: string
                    enum:
                      - in_app
                      - email
                      - whatsapp
                  default:
                    - in_app
                  description: >-
                    Where this message should go. Omitted is `["in_app"]`, which
                    is what this endpoint did before the field existed — so a
                    backend written against the old shape does not start sending
                    mail.


                    The response carries a verdict per channel rather than one
                    status code for all of them: a contact with no phone number
                    is not a malformed request, and it must not cost them the
                    in-app notification they could have had. Each channel
                    answers `created` (the feed row, written before the
                    response), `queued` (handed to the queue — the delivery log
                    is where "did it arrive" is answered) or `skipped` with the
                    reason to repair. Nothing leaving by any door is a `422`.


                    `email` needs a contact with an email address. `whatsapp`
                    needs three things: a server connected on this environment,
                    a `phone` on the contact, and something to say.
                email:
                  type: object
                  description: What only an inbox needs.
                  properties:
                    subject:
                      type: string
                      description: >-
                        Defaults to the notification's title. An email has an
                        envelope and a feed row does not, which is the whole
                        reason this block exists.
                whatsapp:
                  type: object
                  description: What only WhatsApp needs.
                  properties:
                    text:
                      type: string
                      description: >-
                        Replaces the composed message entirely. Composed, it is
                        the title, the body, and the destination — the last only
                        when it is an absolute URL, since a rooted path is not a
                        link anywhere outside your own app.
                kind:
                  type: string
                  enum:
                    - action
                    - update
                  default: update
                  description: >-
                    Optional. `action` when the person has something to do — the
                    card was declined, the document needs a signature — and
                    `update` when you are telling them what already happened.


                    It defaults to `update`, and the asymmetry is deliberate: a
                    notification wrongly filed as an update sits in a list,
                    while one wrongly filed as an action puts a demand on
                    somebody who has nothing to do. Say `action` when you know.
            example:
              external_id: user_8421
              template: exportacao_pronta
              kind: update
              data:
                arquivo: Q3.csv
                dias: '7'
      responses:
        '201':
          description: >-
            At least one channel took the message. `channels` carries the
            verdict per channel; `notification` is present only when `in_app`
            was among them, since it is the one channel that writes a row.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
            X-RateLimit-Scope:
              $ref: '#/components/headers/RateLimitScope'
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: object
                    description: >-
                      One entry per channel you asked for, keyed by its name. A
                      channel that did not deliver is reported here rather than
                      raised: a contact with no phone number must not cost them
                      the in-app notification they could have had.
                    additionalProperties:
                      $ref: '#/components/schemas/NotificationChannelVerdict'
                    example:
                      in_app:
                        status: created
                        id: 0f0e0d0c-0b0a-4090-8070-605040302010
                      email:
                        status: skipped
                        reason: the contact has no email address
                  notification:
                    $ref: '#/components/schemas/Notification'
        '400':
          description: >-
            `invalid_request` — no recipient, both recipients, no `template`, an
            unknown `kind`, an unknown channel, or a `url` that is neither a
            rooted path nor an absolute `http(s)` URL. `missing_template_data` —
            a variable the template declares was not sent, named.
            `ambiguous_customer` / `unknown_customer` — the team half.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            `not_found` — no contact of this key's environment answers to that
            `contact_id` or `external_id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          $ref: '#/components/responses/IdempotencyKeyInFlight'
        '422':
          description: >-
            `nothing_delivered` — the request was well formed and no channel
            could deliver it: no template under that key on any channel you
            asked for, a template that is not `transactional`, a contact with no
            address on the only channel asked, or the month's email allowance
            spent. The message names the **first** reason in the order you
            listed the channels, which is the one to repair.


            Also `idempotency_key_reused` — this `Idempotency-Key` was already
            used for a different request on this route. Replaying the first
            response would answer a call that never happened, so it is refused
            instead. Use a new key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/ApiKeyRateLimited'
      security:
        - apiKey: []
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: >-
        A key you choose — a UUID, or your own identifier for the operation —
        that makes this write safe to retry. Send the same key again and you get
        the same response back: the same status, the same body, and
        `Idempotent-Replay: true`. The handler does not run a second time.


        The key is scoped to your API key and the route, so keys never collide
        between environments or tenants. It is remembered for 24 hours. Reusing
        it for a different request is refused (`422`), and a second request
        arriving while the first is still running is refused too (`409`) — retry
        that one in a moment.


        A request that failed with a `5xx` or was rate limited leaves the key
        spendable: retry it with the same key.
  headers:
    RateLimitLimit:
      schema:
        type: integer
      description: >-
        Requests allowed in the current window, for whichever ceiling is closest
        to running out — `X-RateLimit-Scope` says which one that is.
    RateLimitRemaining:
      schema:
        type: integer
      description: >-
        Requests left in the current window under that same ceiling. Zero means
        the next request is refused.
    RateLimitReset:
      schema:
        type: integer
      description: >-
        Seconds until the window resets — a *delta*, which is what this header
        name means in the IETF draft. On a `429` it is the same number as
        `Retry-After`.
    XRateLimitLimit:
      schema:
        type: integer
      description: >-
        The same number as `RateLimit-Limit`, under the name most existing
        clients already read.
    XRateLimitRemaining:
      schema:
        type: integer
      description: The same number as `RateLimit-Remaining`.
    XRateLimitReset:
      schema:
        type: integer
      description: >-
        When the window resets, as a Unix epoch second — the *absolute* form
        that goes with this header name. It marks the same instant
        `RateLimit-Reset` expresses as a delta; the two units are deliberate,
        not a mistake.
    RateLimitScope:
      schema:
        type: string
        enum:
          - key
          - environment
      description: >-
        Which ceiling the three numbers describe: `key` is this API key's own
        allowance, `environment` the one it shares with every other key of the
        same environment. Informational — branch on the numbers, not on this.
  schemas:
    NotificationChannelVerdict:
      type: object
      description: >-
        What one door did with one message. Only `in_app` is synchronous: the
        row is written before the response, so the person can see it
        immediately. The other two are handed to the queue and answer `queued`
        rather than `sent` — the delivery log is where "did it arrive" is
        answered.
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - created
            - queued
            - skipped
          description: >-
            `created` — the feed row exists (`in_app` only). `queued` — the job
            was accepted by the queue. `skipped` — this channel delivered
            nothing, and `reason` says what to repair.
        id:
          type: string
          format: uuid
          description: The notification's id. Present only on `in_app` with `created`.
        reason:
          type: string
          description: >-
            Why this channel delivered nothing, in the words to act on: no
            template with that key on this channel, a template that is not
            `transactional`, a contact with no email address or no phone, no
            WhatsApp server connected on this environment, or the month's email
            allowance spent. Present only with `skipped` — read `status`, never
            the presence of this field.
    Notification:
      type: object
      description: >-
        One in-app message addressed to one person. Unlike an email's delivery
        record this row **is** the delivery — the body exists nowhere else,
        which is why it is here and why it dies with the contact.
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        body:
          type: string
          description: Empty when the notification is a title alone.
        url:
          type: string
          nullable: true
          description: >-
            Where this notification leads, or `null` when it leads nowhere.
            Either a path rooted at `/` or an absolute `http`/`https` URL —
            never a scheme that executes, which the write refuses and the
            database refuses under it.
        action:
          type: string
          nullable: true
          enum:
            - agreements
            - null
          description: >-
            A surface of the SDK to OPEN, rather than a place to go — `null` on
            almost every row.


            It exists because a destination and a thing-to-open are different
            answers and `url` can only hold the first: the API refuses any
            scheme there, so "open the agreements card" could not be spelled as
            an address without inventing one every client would have to learn
            not to navigate to.


            A row carries a `url` or an `action`, never both — a notification
            has one button. The set is closed at the database, so a value here
            is one the SDK implements: `@userkit/react` draws a button that
            calls `agreements.show()`.
        customer_id:
          type: string
          format: uuid
          nullable: true
          description: >-
            The team this was said INSIDE, or `null` when it is the person's
            own.


            `null` is an answer rather than a gap: "sua senha foi alterada"
            belongs to nobody's team in particular, so it is listed inside
            **every** team of theirs. Every row written before this field
            existed is that case too — nothing was attributed retroactively,
            because dating an old message to today's memberships would file it
            under a relationship that may not have existed then.


            The feed is already scoped by the time you read it, so this is what
            labels a row rather than what filters one.
        category:
          type: string
          enum:
            - transactional
            - marketing
            - product_news
            - surveys
          description: >-
            Which consent governed it. `transactional` is legal here and illegal
            in `notification_preferences`, and that asymmetry is what makes
            these unsuppressable.
        source:
          type: string
          enum:
            - transactional
            - campaign
          description: >-
            Which machinery produced it. Not `category` spelled differently: a
            dunning notice will be a campaign that nobody may unsubscribe from.
        kind:
          type: string
          enum:
            - action
            - update
          description: >-
            Whether this asks the person for something (`action`) or tells them
            what already happened (`update`). A third question neither
            `category` nor `source` can answer: those two describe how the row
            was produced, and this describes what the reader has to do about it.
            A panel groups `action` under a tab of its own.
        read:
          type: boolean
        read_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    Error:
      type: object
      description: Every error in this API answers this envelope.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Stable. Branch on this.
            message:
              type: string
              description: For a person. May change.
          required:
            - code
            - message
      required:
        - error
      example:
        error:
          code: forbidden
          message: your role does not allow this action
  responses:
    Unauthorized:
      description: >-
        `unauthorized` — missing, malformed, expired or revoked credential, and
        nothing else: it says the credential is gone, so a client is right to
        discard it and sign in again. A well-formed credential from the wrong
        family says so: "this endpoint expects a staff session token, not an
        organization API key". A LIVE staff session aimed at an organization it
        is not a member of is `not_a_member` (403), never this.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    IdempotencyKeyInFlight:
      description: >-
        `idempotency_key_in_flight` — another request carrying this
        `Idempotency-Key` is still running. Exactly one of them reaches the
        handler, by construction; retry in a moment and you will get that
        request's answer replayed.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ApiKeyRateLimited:
      description: >-
        `rate_limited` — this API key has spent its allowance for the current
        minute, or the environment's keys have spent theirs between them.
        `Retry-After` says how long is left of the window, and the `RateLimit-*`
        headers say which of the two ceilings refused.


        Nothing durable happened, so the retry is free: a throttled request
        never claims an `Idempotency-Key`, and the same key is still spendable
        on the next attempt.


        The counters are shared across instances. When that store cannot be
        reached each instance counts on its own instead — the limits get looser,
        never absent, and `RateLimit-Remaining` is then that instance's
        remainder rather than the whole fleet's.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the window resets.
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
        X-RateLimit-Scope:
          $ref: '#/components/headers/RateLimitScope'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    sessionToken:
      type: http
      scheme: bearer
      description: >-
        A staff session token, `uk_st_…`. Minted by sign-up, sign-in or the
        two-factor exchange. Only a 401 means it is spent; `not_a_member` (403)
        is about the organization named in `X-Organization-Id` and leaves the
        token good for the others.
    apiKey:
      type: http
      scheme: bearer
      description: >-
        An organization API key, `uk_sk_live_…` or `uk_sk_test_…`. The
        environment is resolved from the stored key row on every request, never
        from a request parameter.


        Every route behind this credential is rate limited **per key** — 1000
        requests a minute — with a second ceiling of 3000 a minute across all
        the keys of one environment, so splitting your traffic across keys
        isolates it and minting more keys does not buy more of it. A test key
        can never spend a live key's allowance. Every response carries the
        current state in headers; see the rate-limits guide.

````