Skip to content
AI Inbx
Esc
navigateopen⌘Jpreview
On this page

Pacing

Sending hours and rate ceilings, the queue they hold mail in, and how to inspect or override it.

Pacing decides when an accepted message actually goes out. Two kinds of rule — sending hours and rate limits — and everything they hold sits in a queue you can read and release from.

Warming a new domain, keeping a shared mailbox under a provider’s ceiling, or simply not sending business mail at 3am on a Sunday: all the same mechanism.

How a send meets the rules

Match

A rule’s match patterns are tested against the message’s sender and recipients. No patterns means the rule matches everything.

Hold or go

Every matching rule gets a say. If any of them says no, the message waits.

Report

The send response’s pacing field names the rule that’s holding it and projects when it will go.

Release

The queue drains on its own as windows open and budget frees up — or you release messages by hand.

const email = await aiinbx.emails.send(payload)

if (email.pacing && email.pacing.held_by.kind !== "ready") {
  console.log(
    "held by rule",
    email.pacing.held_by.rule_id,
    "until ~",
    email.pacing.estimated_send_at
  )
}

held_by.kind is ready (going now), hours (outside a sending window), or limit (a rate ceiling is full). estimated_send_at is a projection from the current queue, not a promise.

Matching

{
  "match": [
    { "field": "from", "pattern": "*@newsletter.example.com" },
    { "field": "to", "pattern": "*@bigcorp.com" }
  ]
}

field is from, to, or either. Patterns match addresses, and * wildcards a segment — so *@example.com is a domain and sales@* is a local part across domains. An empty match array applies the rule to every send.

Sending hours

An hours rule holds mail outside the windows you declare:

await aiinbx.pacingRules.create({
  name: "Business hours only",
  kind: "hours",
  match: [{ field: "from", pattern: "*@sales.example.com" }],
  schedule: {
    timezone: "Europe/Berlin",
    windows: [
      { days: [1, 2, 3, 4, 5], start_minute: 540, end_minute: 1020 },
    ],
  },
})
client.pacing_rules.create(
    name="Business hours only",
    kind="hours",
    match=[{"field": "from", "pattern": "*@sales.example.com"}],
    schedule={
        "timezone": "Europe/Berlin",
        "windows": [
            {"days": [1, 2, 3, 4, 5], "start_minute": 540, "end_minute": 1020}
        ],
    },
)
curl https://api.aiinbx.com/api/v2/pacing-rules \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Business hours only",
    "kind": "hours",
    "match": [{ "field": "from", "pattern": "*@sales.example.com" }],
    "schedule": {
      "timezone": "Europe/Berlin",
      "windows": [{ "days": [1,2,3,4,5], "start_minute": 540, "end_minute": 1020 }]
    }
  }'
PropType
timezonestring

IANA name, e.g. Europe/Berlin. Windows are evaluated in it, so DST is handled for you.

Typestring
daysnumber[]

Days the window applies to. 0 is Sunday, 6 is Saturday.

Typenumber[]
start_minutenumber

Minutes past local midnight. 540 is 09:00.

Typenumber
end_minutenumber

Minutes past local midnight. 1020 is 17:00; 1440 is end of day.

Typenumber

Several windows can coexist — weekday mornings plus Saturday afternoon, say. A message is free to go when it falls inside any of them.

Rate limits

A limit rule caps sends per rolling window:

await aiinbx.pacingRules.create({
  name: "Warm the new domain",
  kind: "limit",
  match: [{ field: "from", pattern: "*@new.example.com" }],
  limit: { scope: "from_domain", amount: 200, per_seconds: 3600 },
})

amount per per_seconds, counted against a scope — which is what the ceiling is per:

Scope The ceiling is per…
rule The rule as a whole. One shared budget for everything it matches.
from_address Each distinct sender address.
from_domain Each distinct sending domain.
to_address Each distinct recipient. Good for “never more than one a day to the same person”.
to_domain Each distinct recipient domain. Good for staying under one company’s gateway limits.
{ "limit": { "scope": "to_address", "amount": 1, "per_seconds": 86400 } }

The window rolls continuously — it isn’t a bucket that resets on the hour.

Spaces

A rule created with space_id reads that space’s mail alone. A rule without one — the workspace’s — applies to every space, and stacks on top of whatever the spaces set: every matching rule gets a say, and any one of them can hold the message.

That is the shape a platform wants. Your rule caps every customer at once; a customer’s own rule, created in their space, can only tighten what they send, never loosen your cap.

// Yours: nobody sends more than this, whichever space they are in.
await aiinbx.pacingRules.create({
  name: "Platform ceiling",
  kind: "limit",
  match: [],
  limit: { scope: "from_domain", amount: 5000, per_seconds: 86_400 },
})

// Theirs: business hours for one customer's mail only.
await aiinbx.pacingRules.create({
  name: "Acme business hours",
  kind: "hours",
  space_id: "spc_...",
  match: [],
  schedule: { timezone: "Europe/Berlin", windows: [{ days: [1, 2, 3, 4, 5], start_minute: 540, end_minute: 1020 }] },
})

match still applies inside a space: an empty array is “everything in this space”, not “everything in the workspace”. pacingRules.list takes space; the queue is the workspace’s, and each held message is held by whichever rule — of its space or of the workspace — said no.

Spread

spread is a workspace-wide dial from 0 to 100 that adds randomness to send timing. At 0, a rate-limited stream goes out on a metronome. Turn it up and each send is recorded a little later than it happened, so the gaps vary instead of being identical.

await aiinbx.pacingRules.spread({ spread: 40 })
client.pacing_rules.spread(spread=40)
curl -X PATCH https://api.aiinbx.com/api/v2/pacing-rules/spread \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "spread": 40 }'

Perfectly regular timing is one of the cheaper signals of automation. A moderate spread costs a little throughput and buys traffic that doesn’t look metronomic. It applies to limit rules; hours rules have nothing to jitter.

The queue

const queue = await aiinbx.pacing.retrieve({ limit: 100 })

console.log(queue.total, "held as of", queue.as_of)

for (const rule of queue.rule_counts) {
  console.log(rule.rule_id, `${rule.held} held of ${rule.matches} matched`)
}

for (const item of queue.data) {
  console.log(item.queued_at, item.from, "→", item.to, item.subject)
}
queue = client.pacing.retrieve(limit=100)
print(queue["total"], queue["as_of"])
curl "https://api.aiinbx.com/api/v2/pacing-queue?limit=100" \
  -H "Authorization: Bearer $AI_INBX_API_KEY"
PropType
totalnumber

Everything currently held.

Typenumber
samplednumber

How many are in `data` — `limit` caps this at 2000, so it can be less than total.

Typenumber
as_ofstring

When the snapshot was taken.

Typestring
spreadnumber

The workspace spread in effect.

Typenumber
rulesPacingRule[]

The rules the snapshot was evaluated against.

TypePacingRule[]
rule_countsobject[]

Per rule: how many messages it matched, and how many it is holding.

Typeobject[]
dataobject[]

A sample of held messages, oldest first.

Typeobject[]

rule_counts is the diagnostic worth looking at first: it tells you which rule is doing the holding, so you tune the one that matters instead of guessing.

Releasing by hand

When something urgent is stuck behind a queue, push it out:

const { data } = await aiinbx.pacing.release({
  email_ids: ["eml_...", "eml_..."],
  count_toward_limits: false,
})
released = client.pacing.release(
    email_ids=["eml_..."],
    count_toward_limits=False,
)
curl https://api.aiinbx.com/api/v2/pacing-queue/release \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email_ids": ["eml_..."], "count_toward_limits": false }'

Up to 500 IDs per call; the response lists the ones actually released. count_toward_limits defaults to true — leave it there unless the release is genuinely exceptional, because false means the send doesn’t consume budget and the rule under-counts.

Better than releasing repeatedly: set pacing.skip on the messages that should never queue in the first place.

{ "pacing": { "skip": true, "count": false } }

Password resets, verification codes, and security alerts belong in that category.

Managing rules

const rules = await aiinbx.pacingRules.list().all()

await aiinbx.pacingRules.update("pace_...", { enabled: false })
await aiinbx.pacingRules.delete("pace_...")
for rule in client.pacing_rules.iter():
    print(rule["name"], rule["kind"], rule["enabled"])

client.pacing_rules.update("pace_...", enabled=False)

enabled: false parks a rule without losing its configuration — the right move when you’re diagnosing which rule is holding mail. Creating and changing rules requires a full scope key; reading the queue does not.

Warming a new domain

A staged limit is the usual pattern. Start conservative and raise amount as reputation builds:

Week amount per_seconds
1 50 86400
2 200 86400
3 1000 86400
4 5000 86400
await aiinbx.pacingRules.update(warmupRuleId, {
  limit: { scope: "from_domain", amount: 200, per_seconds: 86_400 },
})

Watch email.bounced and email.complained between steps. Rising complaints mean hold, not advance.

Last updated on September 9, 2026

Was this page helpful?