A valid request URL is required to generate request examples{
"channels": {
"in_app": {
"status": "created",
"id": "0f0e0d0c-0b0a-4090-8070-605040302010"
},
"email": {
"status": "skipped",
"reason": "the contact has no email address"
}
},
"notification": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"title": "<string>",
"body": "<string>",
"url": "<string>",
"action": "agreements",
"customer_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"category": "transactional",
"source": "transactional",
"kind": "action",
"read": true,
"read_at": "2023-11-07T05:31:56Z",
"created_at": "2023-11-07T05:31:56Z"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}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.
A valid request URL is required to generate request examples{
"channels": {
"in_app": {
"status": "created",
"id": "0f0e0d0c-0b0a-4090-8070-605040302010"
},
"email": {
"status": "skipped",
"reason": "the contact has no email address"
}
},
"notification": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"title": "<string>",
"body": "<string>",
"url": "<string>",
"action": "agreements",
"customer_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"category": "transactional",
"source": "transactional",
"kind": "action",
"read": true,
"read_at": "2023-11-07T05:31:56Z",
"created_at": "2023-11-07T05:31:56Z"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}Authorizations
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.
Headers
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.
255Body
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.
"fatura_vencendo"
Whose notification this is. Exactly one of contact_id or external_id.
The same person, named by your own primary key.
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.
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.
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.
Show child attributes
Show child attributes
{
"invoice_id": "inv_5512",
"valor": "R$ 149,00",
"vencimento": "05/09"
}
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.
2048Where 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.
in_app, email, whatsapp What only an inbox needs.
Show child attributes
Show child attributes
What only WhatsApp needs.
Show child attributes
Show child attributes
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.
action, update Response
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.
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.
Show child attributes
Show child attributes
{
"in_app": {
"status": "created",
"id": "0f0e0d0c-0b0a-4090-8070-605040302010"
},
"email": {
"status": "skipped",
"reason": "the contact has no email address"
}
}
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.
Show child attributes
Show child attributes