ANS
Publish an event once. Deliver it to webhooks, Workers, queues, and email.
ANS (Anything Notification Service) is a managed topic and subscription service. A producer publishes a JSON event to a topic. ANS stores the message durably, evaluates each subscription's filter, and fans the event out asynchronously. A destination can be an HTTPS webhook, an existing Worker, an existing AQS queue, or an AMS email action.
ANS is built into AnythingCloud: it uses your account, personal API keys, Workers, queues, sending domains, verified domains, and the same Points balance and usage meter as the rest of the platform. It does not introduce a second account or a separate queue or mail engine.
Quick start
Create a personal API key in Settings → API keys and export it. Every request uses the same bearer authentication as the rest of the AnythingCloud API.
export ANS_API_KEY="ac_..."
export ANS_API="https://anything.solutions/v1/ans"
Create a topic in one region:
curl -s -X POST "$ANS_API/topics" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order-events",
"region": "us-east-1",
"retention_hours": 168
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
headers = {"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"}
response = requests.post(
f"{API}/topics",
headers=headers,
json={
"name": "order-events",
"region": "us-east-1",
"retention_hours": 168,
},
timeout=30,
)
response.raise_for_status()
print(response.json()["topic"]["id"])
const API = "https://anything.solutions/v1/ans";
const headers = {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
};
const response = await fetch(`${API}/topics`, {
method: "POST",
headers,
body: JSON.stringify({
name: "order-events",
region: "us-east-1",
retention_hours: 168,
}),
});
if (!response.ok) throw new Error(await response.text());
const {topic} = await response.json();
console.log(topic.id);
{
"ok": true,
"topic": {
"id": "tpc_4f8c...",
"arn": "ans:us-east-1:42:topic/order-events",
"name": "order-events",
"slug": "order-events",
"region": "us-east-1",
"status": "active",
"retention_hours": 168,
"max_message_bytes": 262144,
"access_policy": {},
"subscription_count": 0,
"active_subscription_count": 0
}
}
Publish the first message. ANS accepts it immediately and delivers it to matching subscriptions in the background.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../publish" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "order.created",
"data": {
"order_id": "12345",
"total": 49.99,
"currency": "USD"
},
"attributes": {"tenant": "acme"}
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
headers = {"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"}
response = requests.post(
f"{API}/topics/tpc_4f8c.../publish",
headers=headers,
json={
"event": "order.created",
"data": {
"order_id": "12345",
"total": 49.99,
"currency": "USD",
},
"attributes": {"tenant": "acme"},
},
timeout=30,
)
response.raise_for_status()
print(response.json()["message_id"])
const API = "https://anything.solutions/v1/ans";
const headers = {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
};
const response = await fetch(
`${API}/topics/tpc_4f8c.../publish`,
{
method: "POST",
headers,
body: JSON.stringify({
event: "order.created",
data: {order_id: "12345", total: 49.99, currency: "USD"},
attributes: {tenant: "acme"},
}),
},
);
if (!response.ok) throw new Error(await response.text());
const {message_id} = await response.json();
console.log(message_id);
{
"ok": true,
"accepted": true,
"message_id": "msg_9a1d...",
"topic_id": "tpc_4f8c...",
"published_at": "2026-11-05T17:45:00Z",
"region": "us-east-1",
"deliveries": 2,
"duplicate": false
}
Topics and subscriptions
A topic is a named event stream in one region. Its
stable id (tpc_...) and ARN do not change when the display
name changes. A topic owns its retention window, message-size limit,
publish access policy, optional rate limit and daily quota, and its
subscriptions.
A subscription is one destination plus a filter and a retry policy. A topic can have up to 50 subscriptions by default. Each matching subscription gets its own delivery status, even when two subscriptions point at the same destination.
Subscription kinds:
- webhook — an HTTPS endpoint you control, signed with HMAC.
- worker — an existing AnythingCloud Worker, invoked on its
eventhandler. - queue — an existing AQS queue, using AQS's storage, permissions, and deduplication.
- email — an AMS sending address with a template rendered from the event.
Authentication and permissions
Send a personal API key as Authorization: Bearer <key>
or X-API-Key: <key>. Session cookies are deliberately
ignored. The account must have AnythingCloud enabled and active. The same
key management page can revoke a key immediately.
Every request is scoped to the key owner. Looking up another member's
topic, subscription, message, or delivery returns 404.
Administrative changes made from the console are written to the platform
audit log.
A topic can additionally restrict publishing with an access policy:
{
"api_key_ids": [17, 23],
"cidrs": ["203.0.113.0/24"],
"require_verified_host": true
}
api_key_ids limits publishing to those personal API keys.
cidrs limits the client address. require_verified_host
rejects the shared API host and accepts only one of the topic's active,
verified custom hosts. An empty policy allows any key on the account.
Workers, AQS, and AMS keep their existing permissions: a Worker subscription needs a Worker owned by the same account, a queue subscription needs an owned ready queue, and an email subscription needs approved AMS access, an active sending domain, and an active sender.
REST API
The primary base URL is /v1/ans. The same routes are also
available under /api/v1/ans for clients that already build
the standard member API base. JSON responses use
{"ok": true, ...} on success and
{"ok": false, "error": "...", "code": "...", "status": 400}
on failure. Timestamps are UTC ISO 8601 with a Z suffix.
| Method and path | Purpose |
|---|---|
GET /v1/ans/health | Service and region health; no key required. |
GET /v1/ans/regions | Available regions and the default region. |
GET /v1/ans/pricing | Current publish, delivery, and retry prices. |
GET /v1/ans/resources | Owned Workers, queues, senders, and verified domains usable as destinations. |
GET /v1/ans/topics | List topics. q, limit, offset. |
POST /v1/ans/topics | Create a topic. |
GET /v1/ans/topics/{id} | Topic details and current stats. Accepts id or slug. |
PATCH /v1/ans/topics/{id} | Update name, description, retention, size, limits, or status. |
DELETE /v1/ans/topics/{id} | Delete a topic and its subscriptions and hosts. |
GET /v1/ans/topics/{id}/policy | Read the publish access policy. |
PUT /v1/ans/topics/{id}/policy | Replace the publish access policy. |
POST /v1/ans/topics/{id}/publish | Publish one message. |
POST /v1/ans/topics/{id}/publish/batch | Publish up to 10 messages. |
GET /v1/ans/topics/{id}/messages | Recent messages. Optional event filter. |
GET /v1/ans/topics/{id}/messages/{message_id} | One message and its delivery counts. |
GET /v1/ans/topics/{id}/subscriptions | List subscriptions. |
POST /v1/ans/topics/{id}/subscriptions | Create a subscription. Returns the webhook secret once. |
GET /v1/ans/subscriptions/{id} | Subscription details and verification state. |
PATCH /v1/ans/subscriptions/{id} | Update filter, retry policy, destination, or region. |
DELETE /v1/ans/subscriptions/{id} | Delete a subscription. |
POST /v1/ans/subscriptions/{id}/enable | Enable or re-verify a subscription. |
POST /v1/ans/subscriptions/{id}/disable | Pause delivery. |
POST /v1/ans/subscriptions/{id}/verify | Request a new webhook verification. |
POST /v1/ans/subscriptions/{id}/secret | Rotate the webhook signing secret. |
GET /v1/ans/topics/{id}/deliveries | Delivery history. status, subscription_id, limit. |
GET /v1/ans/subscriptions/{id}/deliveries | Delivery history for one subscription. |
GET /v1/ans/deliveries/{id} | One delivery with its attempt history. |
POST /v1/ans/deliveries/{id}/retry | Redrive a failed or dead delivery. |
POST /v1/ans/topics/{id}/redrive | Redrive all failed/dead deliveries, optionally one subscription. |
GET /v1/ans/topics/{id}/metrics | Daily series and totals. days=7|14|30|90. |
GET /v1/ans/metrics | Account-wide series, topic rollup, and subscription rollup. |
GET /v1/ans/topics/{id}/hosts | Custom publish hosts. |
POST /v1/ans/topics/{id}/hosts | Add a host under a verified root domain. |
DELETE /v1/ans/topics/{id}/hosts/{route_id} | Remove a custom host. |
Publishing messages
A message has an event, a JSON object in data,
optional flat attributes, and optional metadata.
The default limits are 256 KiB for the serialized message, 50 attributes
(8 KiB total), and 20 metadata fields (4 KiB total). All can be reduced
per topic; a topic cannot exceed the platform's configured maximum.
The event name is 1–160 characters: letters, numbers, dots, dashes,
underscores, colons, and slashes. order.created,
billing.invoice.paid, and deploy/production are
valid.
ANS stores the message before acknowledging the request. Delivery then
happens asynchronously. The response returns 202 for a new
message and 200 when an idempotent retry returned the original
message.
Idempotency
Send Idempotency-Key as a header, or
idempotency_key in the body. The key must be 8–128
printable ASCII characters. Reusing it with the same event and payload
returns the original message with duplicate: true; reusing it
with different content returns 409 idempotency_conflict.
Keys are scoped to one topic.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../publish" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Idempotency-Key: order-12345-created" \
-H "Content-Type: application/json" \
-d '{"event":"order.created","data":{"order_id":"12345"}}'
import os
import requests
API = "https://anything.solutions/v1/ans"
response = requests.post(
f"{API}/topics/tpc_4f8c.../publish",
headers={
"Authorization": f"Bearer {os.environ['ANS_API_KEY']}",
"Idempotency-Key": "order-12345-created",
},
json={"event": "order.created", "data": {"order_id": "12345"}},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../publish",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-12345-created",
},
body: JSON.stringify({
event: "order.created",
data: {order_id: "12345"},
}),
},
);
if (!response.ok) throw new Error(await response.text());
Batch publishing
Send up to 10 messages in messages. Each item is validated
and published independently, so a bad item does not roll back the
accepted ones. The response reports the index, accepted flag, message, and
delivery count for each item.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../publish/batch" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"event":"order.created","data":{"order_id":"A-1"},"idempotency_key":"A-1"},
{"event":"order.created","data":{"order_id":"A-2"},"idempotency_key":"A-2"}
]
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
headers = {"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"}
response = requests.post(
f"{API}/topics/tpc_4f8c.../publish/batch",
headers=headers,
json={
"messages": [
{"event": "order.created", "data": {"order_id": "A-1"}},
{"event": "order.created", "data": {"order_id": "A-2"}},
]
},
timeout=30,
)
response.raise_for_status()
for item in response.json()["results"]:
print(item["index"], item["accepted"], item.get("message", {}).get("id"))
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../publish/batch",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [
{event: "order.created", data: {order_id: "A-1"}},
{event: "order.created", data: {order_id: "A-2"}},
],
}),
},
);
if (!response.ok) throw new Error(await response.text());
const {results} = await response.json();
for (const item of results) {
console.log(item.index, item.accepted, item.message?.id);
}
Message filtering
A subscription with an empty filter receives every event. A filter is a
JSON object with any of event, data, and
attributes. Filters are evaluated when the message is
published, not when it is delivered.
{
"event": ["order.created", "order.refunded"],
"data": {
"currency": "USD",
"total": {"numeric": [">", 100]},
"customer.country": "US",
"channel": {"prefix": "web"},
"gift": {"exists": true}
},
"attributes": {"tenant": ["acme", "globex"]}
}
- A string matches exactly. A trailing
*matches a prefix. - A list means any listed value matches (for strings, numbers, and booleans).
- On a data or attribute path, a list in the message also matches when any element matches.
{"prefix": "web"}matches a string prefix.{"numeric": [">", 100]}supports>,>=,<,<=, and=.{"exists": true}requires the path;falserequires it to be absent or null.- A dotted path (
customer.country) walks nested objects.
HTTP webhook subscriptions
Create a webhook subscription with an HTTPS URL. The request to the destination is a POST with a JSON body and these headers:
X-ANS-Signature: t=<unix>,v1=<hex>X-ANS-Timestamp: <unix>X-ANS-Topic: tpc_...X-ANS-Message-Id: msg_...X-ANS-Delivery-Id: dlv_...X-ANS-Event: order.createdUser-Agent: AnythingCloud-ANS/1
Custom headers can be added, except headers ANS owns
(Host, Content-Length,
Transfer-Encoding, Connection, and the
X-ANS-* set). Header values must be ASCII and at most 1024
characters.
Create one. The response includes the signing secret exactly once.
The subscription stays pending until the endpoint answers the
verification request with a 2xx status.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "warehouse-hook",
"kind": "webhook",
"endpoint_url": "https://example.com/ans/events",
"headers": {"X-Environment": "production"},
"filter_policy": {"event": ["order.*"]},
"max_attempts": 8,
"base_delay_seconds": 5,
"max_delay_seconds": 3600,
"timeout_seconds": 10,
"dead_letter_queue_id": 12
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
headers = {"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"}
response = requests.post(
f"{API}/topics/tpc_4f8c.../subscriptions",
headers=headers,
json={
"name": "warehouse-hook",
"kind": "webhook",
"endpoint_url": "https://example.com/ans/events",
"headers": {"X-Environment": "production"},
"filter_policy": {"event": ["order.*"]},
"max_attempts": 8,
"base_delay_seconds": 5,
"max_delay_seconds": 3600,
"timeout_seconds": 10,
"dead_letter_queue_id": 12,
},
timeout=30,
)
response.raise_for_status()
subscription = response.json()["subscription"]
secret = response.json()["secret"] # shown once
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "warehouse-hook",
kind: "webhook",
endpoint_url: "https://example.com/ans/events",
headers: {"X-Environment": "production"},
filter_policy: {event: ["order.*"]},
max_attempts: 8,
base_delay_seconds: 5,
max_delay_seconds: 3600,
timeout_seconds: 10,
dead_letter_queue_id: 12,
}),
},
);
if (!response.ok) throw new Error(await response.text());
const {subscription, secret} = await response.json(); // secret shown once
{
"ok": true,
"subscription": {
"id": "sub_7c2a...",
"name": "warehouse-hook",
"kind": "webhook",
"status": "pending",
"region": "us-east-1",
"destination": {
"url": "https://example.com/ans/events",
"headers": {"X-Environment": "production"},
"secret_hint": "whsec_abc1…"
}
},
"secret": "whsec_...",
"secret_note": "Store this signing secret now; it is shown once."
}
Webhook verification
ANS sends a signed POST with type: "ans.subscription.verification" and a
random challenge. Answer with any 2xx status. The subscription
becomes active and future messages are delivered. Up to five
attempts are made with backoff; after that the status is
failed and the console offers Verify endpoint
again. The verification request also carries the normal signature
headers, so the same verification code can be reused.
{
"type": "ans.subscription.verification",
"challenge": "7e3c...",
"attempt": 1,
"timestamp": "2026-11-05T17:45:00Z",
"subscription": {"id": "sub_7c2a...", "name": "warehouse-hook", "region": "us-east-1"},
"topic": {"id": "tpc_4f8c...", "arn": "ans:us-east-1:42:topic/order-events", "name": "order-events"}
}
Webhook signatures and verification
The signature is HMAC-SHA256 with the subscription secret over the exact
bytes <timestamp>.<raw request body>. The header is
t=<timestamp>,v1=<hex digest>. Verify the
signature before parsing the JSON, then reject a timestamp older than a
few minutes to stop replayed requests. The delivery id is stable across
retries, so use it as the idempotency key if the same event can arrive
twice.
import hashlib
import hmac
import time
def verify_ans_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
timestamp = parts.get("t", "")
signature = parts.get("v1", "")
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
import crypto from "node:crypto";
import express from "express";
export function verifyAnsSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => part.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1 || "", "hex"),
);
}
const app = express();
app.post(
"/ans/events",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8");
if (!verifyAnsSignature(raw, req.get("X-ANS-Signature") || "", process.env.ANS_SECRET)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(raw);
if (event.type === "ans.subscription.verification") {
return res.status(204).end();
}
// At-least-once delivery: deduplicate on event.delivery.id.
console.log(event.event, event.data);
res.status(204).end();
},
);
Worker subscriptions
A Worker subscription invokes the Worker's event handler.
Pick any Worker you own; its id is returned by
GET /v1/ans/resources and listed next to its name in the
console. The Worker's own permissions, memory, timeout, logs, and metrics
limits still apply.
The Worker should be live. While it is paused, draft, starting, or errored, ANS retries the delivery and records the reason in the attempt history instead of invoking it. Deleting a Worker pauses its subscriptions, so a publish never fans out to a deleted destination.
ANS subscriptions that target a Worker also appear on the Worker's Triggers tab, where you can pause and resume them or open the subscription in ANS.
Create the subscription with an owned Worker id:
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "fulfilment-worker",
"kind": "worker",
"worker_id": 9
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
response = requests.post(
f"{API}/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={
"name": "fulfilment-worker",
"kind": "worker",
"worker_id": 9,
},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "fulfilment-worker",
kind: "worker",
worker_id: 9,
}),
},
);
if (!response.ok) throw new Error(await response.text());
The handler receives the normal event envelope plus
kind: "event", source: "ans", and a short
ans metadata block:
{
"kind": "event",
"source": "ans",
"type": "ans.message",
"id": "msg_9a1d...",
"event": "order.created",
"topic": {
"id": "tpc_4f8c...",
"arn": "ans:us-east-1:42:topic/order-events",
"name": "order-events",
"region": "us-east-1"
},
"region": "us-east-1",
"published_at": "2026-11-05T17:45:00Z",
"data": {"order_id": "12345"},
"attributes": {},
"metadata": {},
"delivery": {
"id": "dlv_7b2e...",
"attempt": 1,
"subscription_id": "sub_7c2a...",
"region": "us-east-1"
},
"ans": {
"topic_id": "tpc_4f8c...",
"topic_arn": "ans:us-east-1:42:topic/order-events",
"message_id": "msg_9a1d...",
"delivery_id": "dlv_7b2e...",
"subscription_id": "sub_7c2a..."
}
}
export default {
async event(event) {
if (event.source !== "ans" || event.event !== "order.created") return;
// event.data, event.attributes, event.metadata
// event.topic, event.region, event.published_at
// event.delivery.id, event.delivery.attempt
// event.ans.message_id, event.ans.topic_arn
console.log("order", event.data.order_id, "from", event.ans.message_id);
},
};
A non-2xx response from the Worker is retried with the subscription's backoff policy. A Worker that stays paused or errored reaches the subscription's attempt limit, so pause the ANS subscription when you intend to stop delivery for a long time.
AQS queue subscriptions
A queue subscription publishes the event envelope into an existing AQS queue. The message is stored by AQS, follows the queue's token permissions and limits, and appears in the AQS console. AQS deduplication prevents the same ANS delivery from being enqueued twice.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "fulfilment-queue",
"kind": "queue",
"queue_id": 12,
"filter_policy": {"event": "order.created"}
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
response = requests.post(
f"{API}/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={
"name": "fulfilment-queue",
"kind": "queue",
"queue_id": 12,
"filter_policy": {"event": "order.created"},
},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "fulfilment-queue",
kind: "queue",
queue_id: 12,
filter_policy: {event: "order.created"},
}),
},
);
if (!response.ok) throw new Error(await response.text());
The queue body is the same JSON envelope as a webhook. It adds
attributes.ans_topic, ans_event,
ans_message_id, and ans_delivery_id for routing
or filtering. Consume it with the queue's AQS token:
curl -s -X POST "https://anything.solutions/queue/v1/fulfilment/consume" \
-H "Authorization: Bearer $AQS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"max_messages": 10, "visibility_seconds": 30}'
import os
import requests
response = requests.post(
"https://anything.solutions/queue/v1/fulfilment/consume",
headers={"Authorization": f"Bearer {os.environ['AQS_TOKEN']}"},
json={"max_messages": 10, "visibility_seconds": 30},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/queue/v1/fulfilment/consume",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AQS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({max_messages: 10, visibility_seconds: 30}),
},
);
if (!response.ok) throw new Error(await response.text());
AMS email subscriptions
An email subscription renders a subject and text or HTML body from the event and hands the message to AMS. AMS still checks the member's approved access, the sending domain's DNS and DKIM state, the sender's active status, quotas, and rate limits. ANS records whether AMS accepted the message separately from final email delivery; the subscription page shows the AMS message status for accepted sends.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order-receipt",
"kind": "email",
"sender_id": 4,
"to": "[email protected]",
"subject": "Order {{ data.order_id }} confirmed",
"text": "Thanks. Your total is {{ data.currency }} {{ data.total }}.",
"html": "<p>Your order <strong>{{ data.order_id }}</strong> is confirmed.</p>"
}'
import os
import requests
API = "https://anything.solutions/v1/ans"
response = requests.post(
f"{API}/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={
"name": "order-receipt",
"kind": "email",
"sender_id": 4,
"to": "[email protected]",
"subject": "Order {{ data.order_id }} confirmed",
"text": "Thanks. Your total is {{ data.currency }} {{ data.total }}.",
"html": "<p>Your order <strong>{{ data.order_id }}</strong> is confirmed.</p>",
},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "order-receipt",
kind: "email",
sender_id: 4,
to: "[email protected]",
subject: "Order {{ data.order_id }} confirmed",
text: "Thanks. Your total is {{ data.currency }} {{ data.total }}.",
html: "<p>Your order <strong>{{ data.order_id }}</strong> is confirmed.</p>",
}),
},
);
if (!response.ok) throw new Error(await response.text());
Placeholders use {{ data.path }}. HTML
bodies escape substituted values; plain-text bodies and subjects insert
them as text. Paths can start with event, data,
attributes, metadata, message, or
topic. Unknown paths render empty. A triple-brace form
({{{ data.html }}}) inserts a value raw; use
it only when you control the source data.
Retries, dead letters, and redrive
Retry policy is per subscription:
max_attempts— 1 to 20 (default 8).base_delay_seconds— 1 to 3600 (default 5).max_delay_seconds— at least the base, at most 86400 (default 3600).timeout_seconds— 1 to 30 (default 10).
The delay after attempt n is exponential with full jitter:
min(max_delay, base * 2^(n-1)) multiplied by a random value
between 0.5 and 1.0. A 2xx status is success. HTTP 408, 425, 429, and 5xx
are retried. Other 4xx responses, TLS verification failures, and blocked
destinations are permanent failures. A network timeout or connection
refusal is retried. Exhausted retries become dead.
If the subscription has a dead_letter_queue_id, ANS writes a
final ans.dead_letter envelope to that AQS queue. The
delivery keeps its error, attempt history, and dead-letter timestamp. A
failed delivery can be redriven from the API or the console; redrive resets
the attempt count but keeps the original history. Repeated failures also
increment the subscription's consecutive-failure counter and auto-disable
it at auto_disable_after (25 by default).
curl -s -X POST "$ANS_API/deliveries/dlv_9b2c.../retry" \
-H "Authorization: Bearer $ANS_API_KEY"
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/deliveries/dlv_9b2c.../retry",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/deliveries/dlv_9b2c.../retry",
{
method: "POST",
headers: {Authorization: `Bearer ${process.env.ANS_API_KEY}`},
},
);
if (!response.ok) throw new Error(await response.text());
Webhook security
Webhook destinations are untrusted. ANS requires HTTPS on ports 443 and 8443, rejects URLs with embedded credentials, and validates the certificate before sending. It does not follow redirects, and it refuses loopback, private, link-local, reserved, and multicast destinations. Customer-supplied headers cannot override the headers ANS sets.
Hosts and domains
A topic can publish through a hostname under a root domain the member has already verified. After DNS points at AnythingCloud, the certificate is issued automatically. The host serves only the topic's publish API and still requires a personal API key.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../hosts" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"root_id": 3, "label": "events"}'
import os
import requests
API = "https://anything.solutions/v1/ans"
response = requests.post(
f"{API}/topics/tpc_4f8c.../hosts",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={"root_id": 3, "label": "events"},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../hosts",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({root_id: 3, label: "events"}),
},
);
if (!response.ok) throw new Error(await response.text());
After TLS activates, publish through the host root or its
/publish path:
curl -s -X POST "https://events.example.com/publish" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event":"order.created","data":{"order_id":"12345"}}'
import os
import requests
response = requests.post(
"https://events.example.com/publish",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={"event": "order.created", "data": {"order_id": "12345"}},
timeout=30,
)
response.raise_for_status()
const response = await fetch("https://events.example.com/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
event: "order.created",
data: {order_id: "12345"},
}),
});
if (!response.ok) throw new Error(await response.text());
Set require_verified_host: true in the topic's access policy
to accept publishes only through an active custom host. Use the
Hosts page in the Cloud Console to see the certificate
state and remove a host.
Multi-region operation
Regions are explicit. A topic is created in one region; its messages and delivery records belong to that region, and the region is part of the topic's ARN and shown in the console. You can publish through any regional API endpoint, and the message is stored in the topic's region.
A subscription defaults to the topic's region. A subscription in another
region must set allow_cross_region: true. Each region delivers
its own subscriptions, so a problem in one region does not stop delivery in
healthy regions. Cross-region delivery is slower than local delivery and
uses the same retry policy.
Ordering and durability
ANS provides at-least-once delivery. It does not promise
exactly-once. If a delivery is interrupted after a destination accepted it,
the same event may be attempted again. Deduplicate on
message.id or delivery.id. AQS consumers get the
same guarantee from AQS.
Ordering is not guaranteed across subscriptions. AQS preserves its own
queue ordering. If order matters, include a sequence number in
data and order in the consumer.
A publish response means ANS accepted the message; it does not mean any destination received it. Failed deliveries are retried according to the subscription policy. Message retention defaults to 168 hours; a message is deleted after its retention window once every delivery is finished.
Rate limits and quotas
| Limit | Default |
|---|---|
| Topics per account | 20 |
| Subscriptions per topic | 50 |
| Message size | 256 KiB |
| Batch size | 10 messages |
| Retention | 1–720 hours |
| Publish rate | Unlimited unless set per topic |
| Daily publish quota | Unlimited unless set per topic |
| API requests | 1,200/minute and 50,000/day per API key |
A topic's publish rate and daily quota apply to every client publishing
to that topic. Exceeding the rate returns 429 rate_limited
with Retry-After. Exceeding the daily quota returns
429 quota_exceeded until the next UTC day.
Pricing and billing
ANS usage is metered into the same Points balance and monthly bill as the rest of AnythingCloud. Current prices: $0.50 per million publishes, $0.50 per million successful deliveries, and $0.10 per million retry attempts. The first 100,000 publishes and 100,000 deliveries each month are included. New topics and subscriptions do not add a flat fee. Usage is visible on the ANS monitoring page and the Cloud billing page.
Error codes and troubleshooting
| Code | Status | Meaning |
|---|---|---|
missing_api_key | 401 | No bearer key or X-API-Key was sent. |
invalid_api_key | 401 | The key is unknown, expired, or revoked. |
cloud_not_enabled | 403 | AnythingCloud is not active for the account. |
topic_limit | 403 | The account has reached its topic limit. |
subscription_limit | 403 | The topic has reached its subscription limit. |
invalid_endpoint | 400 | The webhook URL is HTTP, uses a disallowed port, or is malformed. |
invalid_filter | 400 | The filter uses an unknown key, operator, or value type. |
invalid_retry | 400 | A retry value is outside its allowed range. |
message_too_large | 413 | The serialized message exceeds the topic's limit. |
request_too_large | 413 | The request body is too large. |
idempotency_conflict | 409 | The key was used with different content. |
rate_limited | 429 | The topic's publish rate limit was reached. Check Retry-After. |
quota_exceeded | 429 | The topic's daily publish quota is used up. |
topic_paused | 409 | The topic is paused. Resume it in the console. |
publish_denied | 403 | The access policy rejected the key, address, or host. |
verified_host_required | 403 | The policy requires an active custom host. |
not_found | 404 | The resource does not exist or belongs to another account. |
A webhook stays pending. The verification request did not receive a 2xx. Check the URL is reachable over HTTPS, the certificate is valid, and the endpoint accepts a POST with the signature headers. Then use Verify endpoint.
A webhook returns 401 from your code. Verify the raw
request body, not a re-serialized object, and use the current secret after
a rotation. The signature header includes the timestamp, so compare with
timestamp.body.
Deliveries are retrying. The destination returned 5xx, 408, 425, or 429, or could not be reached. Open the subscription page and inspect the attempt history and response codes. A permanent 4xx is not retried.
No email arrives. Check AMS approval, the sending domain's DNS/DKIM state, the sender's active status, and the AMS quotas. The delivery page shows whether AMS accepted the message and the AMS message status once it is known.
End-to-end example
This example creates one topic with three subscriptions and publishes
order.created. The Worker sends a fulfilment request, the
queue holds the event for a batch process, and AMS emails the customer.
1. Create the topic
curl -s -X POST "$ANS_API/topics" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"order-events","region":"us-east-1"}'
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/topics",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={"name": "order-events", "region": "us-east-1"},
timeout=30,
)
response.raise_for_status()
const response = await fetch("https://anything.solutions/v1/ans/topics", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({name: "order-events", region: "us-east-1"}),
});
if (!response.ok) throw new Error(await response.text());
2. Add the Worker
The Worker must already exist. Its event handler is:
export default {
async event(event) {
if (event.source !== "ans" || event.event !== "order.created") return;
const response = await fetch("https://fulfilment.example.com/orders", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
order_id: event.data.order_id,
total: event.data.total,
currency: event.data.currency,
source_message: event.id,
}),
});
if (!response.ok) throw new Error(`fulfilment HTTP ${response.status}`);
},
};
The new subscription also appears on the Worker's Triggers tab.
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"fulfilment-worker","kind":"worker","worker_id":9}'
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={"name": "fulfilment-worker", "kind": "worker", "worker_id": 9},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "fulfilment-worker",
kind: "worker",
worker_id: 9,
}),
},
);
if (!response.ok) throw new Error(await response.text());
3. Add the AQS queue
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"order-queue","kind":"queue","queue_id":12}'
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={"name": "order-queue", "kind": "queue", "queue_id": 12},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({name: "order-queue", kind: "queue", queue_id: 12}),
},
);
if (!response.ok) throw new Error(await response.text());
4. Add the AMS email
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../subscriptions" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name":"order-receipt",
"kind":"email",
"sender_id":4,
"to":"[email protected]",
"subject":"Order {{ data.order_id }} confirmed",
"text":"Thanks. Total: {{ data.currency }} {{ data.total }}."
}'
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
headers={"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"},
json={
"name": "order-receipt",
"kind": "email",
"sender_id": 4,
"to": "[email protected]",
"subject": "Order {{ data.order_id }} confirmed",
"text": "Thanks. Total: {{ data.currency }} {{ data.total }}.",
},
timeout=30,
)
response.raise_for_status()
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../subscriptions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "order-receipt",
kind: "email",
sender_id: 4,
to: "[email protected]",
subject: "Order {{ data.order_id }} confirmed",
text: "Thanks. Total: {{ data.currency }} {{ data.total }}.",
}),
},
);
if (!response.ok) throw new Error(await response.text());
5. Publish the event
curl -s -X POST "$ANS_API/topics/tpc_4f8c.../publish" \
-H "Authorization: Bearer $ANS_API_KEY" \
-H "Idempotency-Key: order-12345-created" \
-H "Content-Type: application/json" \
-d '{
"event":"order.created",
"data":{"order_id":"12345","total":49.99,"currency":"USD"},
"attributes":{"tenant":"acme"}
}'
import os
import requests
response = requests.post(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../publish",
headers={
"Authorization": f"Bearer {os.environ['ANS_API_KEY']}",
"Idempotency-Key": "order-12345-created",
},
json={
"event": "order.created",
"data": {"order_id": "12345", "total": 49.99, "currency": "USD"},
"attributes": {"tenant": "acme"},
},
timeout=30,
)
response.raise_for_status()
print(response.json()["message_id"])
const response = await fetch(
"https://anything.solutions/v1/ans/topics/tpc_4f8c.../publish",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-12345-created",
},
body: JSON.stringify({
event: "order.created",
data: {order_id: "12345", total: 49.99, currency: "USD"},
attributes: {tenant: "acme"},
}),
},
);
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
console.log(result.message_id, result.deliveries);
The Worker receives the event envelope shown above, the queue holds the same envelope, and AMS receives the rendered subject and body. Open the topic's Monitoring tab to watch deliveries, retries, and failures, and the subscription page to inspect each attempt.
Cloud Console
Open Cloud → ANS. The overview lists topics and today's activity. A topic page has five tabs: Overview, Subscriptions, Messages, Monitoring, and Settings. Every action available in the API is also available there, including publish test messages, filters, retry settings, dead-letter queues, signing-secret rotation, verification, redrive, and custom publish hosts.
SDK-friendly examples
curl -s "$ANS_API/topics" \
-H "Authorization: Bearer $ANS_API_KEY"
import os
import requests
API = "https://anything.solutions/v1/ans"
headers = {"Authorization": f"Bearer {os.environ['ANS_API_KEY']}"}
topics = requests.get(f"{API}/topics", headers=headers, timeout=30).json()
for topic in topics["items"]:
print(topic["id"], topic["arn"])
const API = "https://anything.solutions/v1/ans";
const headers = {
Authorization: `Bearer ${process.env.ANS_API_KEY}`,
"Content-Type": "application/json",
};
const topics = await fetch(`${API}/topics`, {headers}).then((r) => r.json());
for (const topic of topics.items) console.log(topic.id, topic.arn);