Inbound & BYO inference

Your model, our rails

agentpush never runs your inference. Inbound messages hit the routing engine, which journals them and — for notify routes — POSTs a signed, versioned envelope to your endpoint. Your agent thinks, then replies with send_message. Prefer polling? That works too.

Inbound routes

An inbound_route is a matching rule owned by your workspace. Routes are independent— every enabled route is evaluated against every inbound message; there is no first-match-wins. Each match journals a delivery event tagged with the route's dispatch_tag (idempotent on message_id+ tag, so webhook redeliveries don't duplicate), and notify routes additionally POST the envelope to your URL.

FieldMeaning
match_typecatch_all · channel_is · keyword_contains · from_contains · subject_contains · gmail_label (the last two are email-shaped)
match_valueThe keyword / substring / label / channel name. Required unless match_type is catch_all.
channelScopes the route to one channel (whatsapp, telegram, mail, …); null applies everywhere.
dispatch_modejournal_only (default) just tags the event; notify also POSTs the envelope to notify_url.
notify_urlRequired for notify. Must be a public https:// destination — loopback, private and link-local addresses are refused (SSRF guard).
notify_secretOptional HMAC key for the signature. Encrypted at rest; responses only ever expose has_notify_secret. Omit to receive unsigned notifies.
dispatch_tagStamped as contact_ref on every matched journal event — your agent-side routing key.
enabled / priorityToggle without deleting; priority is informational ordering only.
create a notify route
POST /tools/inbound_route_create
{
  "name": "support-agent",
  "channel": "whatsapp",              // null = every channel
  "match_type": "catch_all",
  "dispatch_tag": "support",          // tags the journaled event
  "dispatch_mode": "notify",
  "notify_url": "https://agent.example.com/agentpush",
  "notify_secret": "whsec_a-long-random-string"
}
// → the route view — notify_secret is never echoed back,
//   only "has_notify_secret": true

Manage routes via the inbound_route_* tools (list / get / create / update / set_enabled / delete) or the REST twins at /inbound-routes.

The signed envelope

What lands on your notify_url is a versioned wire contract — MessagingInboundEnvelope v1:

POST <notify_url> — application/json
{
  "version": 1,
  "workspaceId": "acme",
  "channel": "whatsapp",
  "providerAccountId": "pa_7f3c…",      // present when a provider account handled it
  "from": "+33612345678",
  "conversationId": "+33612345678",
  "messageId": "wamid.HBgL…",
  "text": "I'd like to change my booking",
  "media": [
    {
      "type": "image",
      "url": "https://…",
      "mimeType": "image/jpeg",
      "size": 182734
    }
  ]
}
  • version is literally 1. Receivers must tolerate additivefields within version 1 — parse leniently, don't reject unknown keys.
  • media items carry metadata plus a url and/or providerMediaId — never inline bytes. Fetch content on demand (for email attachments, use messaging_attachment_fetch).
  • conversationId falls back to from on channels without a distinct conversation concept.

Verifying the signature

When the route has a notify_secret, agentpush signs the exact raw request body and sends:

header
X-Agentpush-Signature: sha256=<hex HMAC-SHA256(notify_secret, rawBody)>
receiver — node
import { createHmac, timingSafeEqual } from "node:crypto"

function verifySignature(
  secret: string,
  rawBody: string,
  header: string | undefined
): boolean {
  if (!header) return false
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
  const received = Buffer.from(header)
  const wanted = Buffer.from(expected)
  return received.length === wanted.length && timingSafeEqual(received, wanted)
}

// Hono receiver — the same shape works in Express, Fastify, Next, …
app.post("/agentpush", async c => {
  // Raw body FIRST: the signature covers the exact bytes on the wire.
  // Never verify against a re-serialization of parsed JSON.
  const rawBody = await c.req.text()
  const signature = c.req.header("x-agentpush-signature")

  if (!verifySignature(process.env.NOTIFY_SECRET, rawBody, signature)) {
    return c.json({ error: "invalid signature" }, 401)
  }

  const envelope = JSON.parse(rawBody)
  // … run your inference on envelope.text, then reply (next section)
  return c.json({ ok: true })
})

Delivery semantics

  • Notifies are at-least-once: a durable notify row is snapshotted before the first attempt, and a worker retry pass re-dispatches failures — make your receiver idempotent on messageId.
  • Each attempt is a POST with a 10-second timeout; any non-2xx response, timeout or egress-guard rejection is journaled as failed.
  • Editing a route later doesn't rewrite in-flight notifies — the URL, secret and payload are snapshotted at match time.

Replying

Your reply is a normal send: call send_message(REST or MCP) using the envelope's channel and from as the target. Replying promptly also (re)opens the session window for follow-up free-form messages.

reply via send_message
curl -X POST "$AGENTPUSH_URL/tools/send_message" \
  -H "Authorization: Bearer apk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "to": { "channel": "'"$CHANNEL"'", "address": "'"$FROM"'" },
    "content": { "text": "Sure — which date works better for you?" }
  }'
# CHANNEL and FROM come straight from the envelope you received.

The pull model

No public endpoint? Run the loop the other way around. Generic inbound Requests (non-messaging webhooks relayed through POST /inbound/webhook/:source) are journaled and can be polled with poll_inbound; the agent wakes on its own schedule, processes what's new and replies with send_message:

POST/tools/poll_inboundalso available over MCP
poll loop
// wake-by-poll: call periodically, advance the cursor
POST /tools/poll_inbound
{ "source": "stripe", "since": 1760012345678, "limit": 50 }

// → journaled inbound events (oldest first) — only *inbound* rows,
//   never the notifies agentpush itself dispatched (no self-wake loops).
// Keep max(timestamp) as the next "since".

For conversational history on messaging channels, use read_thread (per contact, all channels merged) or the inbox endpoints (GET /inbox/senders, GET /inbox/thread). The counterpart of the pull model for outbound generic payloads is dispatch_request — fire-and-forget signed POSTs through the same egress guard, always journaled.