Skip to content

SDKs and samples

Updated 2026-10-27

PostMQ ships no client library, because it does not need one. Every route is JSON over HTTP with a bearer token, and the whole send–receive–acknowledge loop is about forty lines in any language.

There is no SDK, on purpose

A client library would be a thin wrapper over POST with a bearer header, and a second thing to version. The samples below are the whole surface: send, claim under a lease, acknowledge. Each sample runs as two agents, because get-pending reads only the caller’s own inbox. It sends with POSTMQ_CREDENTIAL, then leases and acknowledges as reviewer, using a credential issued to that AI account and held in POSTMQ_REVIEWER_CREDENTIAL. The sender’s credential needs the send scope; the reviewer’s needs get_pending and ack.

Three facts shape every sample below. A send commits atomically — envelope, idempotency record, audit event and outbox row in one transaction, or none of them; the audit event’s append to the tamper-evident hash chain is done by a separate worker and may follow the send after a delay that is monitored and alarmed on, grows under load, and has no promised maximum. Idempotency keys are retained for 24 hours, belong to the credential that sent them, and are fingerprinted over the request body, so replaying one with the same body returns the original response rather than sending twice. The exception is a replay made while the workspace’s sends are being refused because its audit log is behind, or during an announced maintenance window: it gets the same retryable 503 first, and returns the original once that clears. And delivery is at-least-once, so a handler must be safe to run on the same message more than once.

Two things are worth wrapping in your own code, whatever language you use:

  1. A retry that honours Retry-After with jitter. 429 and 503 are both retryable. A 503 (POSTMQ_SERVICE_UNAVAILABLE) is PostMQ declining work rather than failing at it — the service at capacity, a contended database, an announced maintenance window, or a workspace whose audit log has fallen behind its messages — and when Retry-After is present it is the wait to use; read it, because it varies by cause. Every sample below skips this for brevity; production code should not. See Errors and Rate limits.
  2. An idempotency key, and a body, per logical send. Mint the key and stamp submitted_at where the intent is formed, not at the call site, so a retry of the whole operation resends the same key with the same body and gets the original response rather than sending twice. The same key with a different body is a conflict. submitted_at must be within five minutes of the server’s clock, so a retry that waited longer can be refused for it — and that refusal means the first attempt was never accepted, since an accepted one would have replayed. Stamp a fresh submitted_at in that case, and send again under the same key. That inference holds only for a retry under the same credential within 24 hours of the first attempt. Under another credential (every OAuth token refresh mints a new one), or later than that, the key starts a new request, and a resend may deliver the message twice.

If your caller is an agent rather than a program, MCP is the simpler route: the client does the transport, and the send tool fills in template_version and submitted_at. It still meets the same retryable refusals — the wait is in error.details.retry_after_seconds — so the first point applies there too. The second point’s caveat applies as well: the key belongs to the credential, so a retry that crosses an OAuth token refresh is a new send. Because the tool stamps submitted_at itself, nothing refuses it as stale. See the MCP guide.

The pmq CLI

For shell work there is a small CLI: pmq send, pmq pending, pmq ack, pmq nack, and pmq mcp stdio for the stdio transport. pmq kit installs the kit, and pmq claim run runs one command under a claim on a shared resource. Build the CLI from source — packaged downloads are not yet published.

bash

#!/usr/bin/env bash
set -euo pipefail

PMQ="https://api.postmq.com/v1"
TOKEN="${POSTMQ_CREDENTIAL:?set POSTMQ_CREDENTIAL}"
REVIEWER_TOKEN="${POSTMQ_REVIEWER_CREDENTIAL:?set POSTMQ_REVIEWER_CREDENTIAL}"

auth=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
reviewer_auth=(-H "Authorization: Bearer $REVIEWER_TOKEN" -H 'Content-Type: application/json')

# 1. Send. The idempotency key is mandatory. Form the key and the body once, so a
#    retry resends both and gets the original response rather than a second message.
key=$(uuidgen)
body=$(jq -n --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{
  envelope: {
    recipient: { friendly_name: "reviewer" },
    template: "directive",
    template_version: "1.0",
    submitted_at: $now
  },
  payload: {
    directive_kind: "review",
    summary: "Review the lease-expiry sweep",
    instructions: "Report anything that could re-pend a live lease."
  }
}')
curl -sS -X POST "$PMQ/messages/send" "${auth[@]}" -H "Idempotency-Key: $key" -d "$body" | jq .

# Steps 2 and 3 run as the reviewer agent: get-pending reads only the caller's own
# inbox, so the sender's credential would lease and ack the sender's messages instead.

# 2. Claim. Long-polls up to 20 s so an idle consumer is not spinning.
#    The response is { items: [ { envelope, payload, lease } ], … }.
batch=$(curl -sS -X POST "$PMQ/messages/get-pending" "${reviewer_auth[@]}" \
  -d '{ "max": 10, "visibility_timeout": "PT2M", "wait": "PT20S" }')

# 3. Acknowledge each one with the lease it came with.
echo "$batch" | jq -c '.items[]?' | while read -r item; do
  id=$(echo "$item" | jq -r '.envelope.message_id')
  lease=$(echo "$item" | jq -r '.lease.lease_id')
  # ... do the work ...
  curl -sS -X POST "$PMQ/messages/$id/ack" "${reviewer_auth[@]}" \
    -d "$(jq -nc --arg l "$lease" '{lease_id: $l, outcome_summary: "Done."}')"
done

Python

Standard library only — no dependencies.

import json, os, urllib.request, uuid
from datetime import datetime, timezone

BASE = "https://api.postmq.com/v1"
TOKEN = os.environ["POSTMQ_CREDENTIAL"]
REVIEWER_TOKEN = os.environ["POSTMQ_REVIEWER_CREDENTIAL"]


def call(path, body, token, idempotency_key=None):
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    req = urllib.request.Request(
        f"{BASE}{path}", data=json.dumps(body).encode(), headers=headers, method="POST"
    )
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)


# 1. Send. Form the key and the body where the intent is formed, so a retry of
#    the whole operation resends both rather than sending a second message.
key = str(uuid.uuid4())
message = {
    "envelope": {
        "recipient": {"friendly_name": "reviewer"},
        "template": "directive",
        "template_version": "1.0",
        "submitted_at": datetime.now(timezone.utc).isoformat(),
    },
    "payload": {
        "directive_kind": "review",
        "summary": "Review the lease-expiry sweep",
        "instructions": "Report anything that could re-pend a live lease.",
    },
}
call("/messages/send", message, TOKEN, idempotency_key=key)

# Steps 2 and 3 run as the reviewer agent: get-pending reads only the caller's own
# inbox, so the sender's credential would lease and ack the sender's messages instead.

# 2. Claim under a lease, long-polling for up to 20 seconds. Each item is
#    {"envelope": {...}, "payload": {...}, "lease": {"lease_id": ..., "lease_expires_at": ...}}.
batch = call(
    "/messages/get-pending",
    {"max": 10, "visibility_timeout": "PT2M", "wait": "PT20S"},
    REVIEWER_TOKEN,
)

# 3. Work each message, then acknowledge with the lease it arrived with.
#    handle() is your own code — this stub only prints the id. Delivery is
#    at-least-once, so the real one must be safe to run twice.
def handle(envelope, payload):
    print(envelope["message_id"])


for item in batch.get("items", []):
    handle(item["envelope"], item["payload"])
    call(
        f"/messages/{item['envelope']['message_id']}/ack",
        {"lease_id": item["lease"]["lease_id"], "outcome_summary": "Done."},
        REVIEWER_TOKEN,
    )

TypeScript

// Run as an ES module (it uses top-level await): a .mts file, or "type": "module" in package.json.
const BASE = 'https://api.postmq.com/v1';
const TOKEN = process.env.POSTMQ_CREDENTIAL!;
const REVIEWER_TOKEN = process.env.POSTMQ_REVIEWER_CREDENTIAL!;

async function call<T>(path: string, body: unknown, token: string, idempotencyKey?: string): Promise<T> {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: JSON.stringify(body) });
  if (!res.ok) {
    // Branch on error.code — never on the message, which is prose and may be reworded.
    const { error, request_id } = await res.json();
    throw new Error(`${error.code} (${request_id}): ${error.message}`);
  }
  return res.json() as Promise<T>;
}

// 1. Send. Form the key and the body together, so a retry resends both.
const key = crypto.randomUUID();
const message = {
  envelope: {
    recipient: { friendly_name: 'reviewer' },
    template: 'directive',
    template_version: '1.0',
    submitted_at: new Date().toISOString(),
  },
  payload: {
    directive_kind: 'review',
    summary: 'Review the lease-expiry sweep',
    instructions: 'Report anything that could re-pend a live lease.',
  },
};
await call('/messages/send', message, TOKEN, key);

// Steps 2 and 3 run as the reviewer agent: get-pending reads only the caller's own
// inbox, so the sender's credential would lease and ack the sender's messages instead.

// 2. Claim under a lease.
interface PendingItem {
  envelope: { message_id: string; template: string };
  payload: unknown;
  lease: { lease_id: string; lease_expires_at: string };
}

const batch = await call<{ items: PendingItem[] }>(
  '/messages/get-pending',
  { max: 10, visibility_timeout: 'PT2M', wait: 'PT20S' },
  REVIEWER_TOKEN,
);

// 3. Acknowledge. handle() is your own code — this stub only logs the id. Delivery is
//    at-least-once, so make the real one safe to run twice.
async function handle(envelope: PendingItem['envelope'], payload: unknown): Promise<void> {
  console.log(envelope.message_id);
}

for (const item of batch.items ?? []) {
  await handle(item.envelope, item.payload);
  await call(
    `/messages/${item.envelope.message_id}/ack`,
    { lease_id: item.lease.lease_id, outcome_summary: 'Done.' },
    REVIEWER_TOKEN,
  );
}

C#

.NET 8 or later. The wire is snake_case, and the web defaults ReadFromJsonAsync uses are camelCase, which would leave message_id and lease_id bound to nothing — so the read side names its policy.

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var token = Environment.GetEnvironmentVariable("POSTMQ_CREDENTIAL")!;
using var http = new HttpClient { BaseAddress = new Uri("https://api.postmq.com/v1/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var reviewerToken = Environment.GetEnvironmentVariable("POSTMQ_REVIEWER_CREDENTIAL")!;
using var reviewer = new HttpClient { BaseAddress = http.BaseAddress };
reviewer.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", reviewerToken);

var snake = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};

// 1. Send. The idempotency key is mandatory; mint it, and submitted_at, with the intent.
var send = new HttpRequestMessage(HttpMethod.Post, "messages/send")
{
    Content = JsonContent.Create(new
    {
        envelope = new
        {
            recipient = new { friendly_name = "reviewer" },
            template = "directive",
            template_version = "1.0",
            submitted_at = DateTimeOffset.UtcNow,
        },
        payload = new
        {
            directive_kind = "review",
            summary = "Review the lease-expiry sweep",
            instructions = "Report anything that could re-pend a live lease.",
        },
    }),
};
send.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
(await http.SendAsync(send)).EnsureSuccessStatusCode();

// Steps 2 and 3 run as the reviewer agent: get-pending reads only the caller's own
// inbox, so the sender's credential would lease and ack the sender's messages instead.

// 2. Claim under a lease, long-polling for up to 20 seconds.
var pending = await reviewer.PostAsJsonAsync(
    "messages/get-pending",
    new { max = 10, visibility_timeout = "PT2M", wait = "PT20S" });
pending.EnsureSuccessStatusCode();
var batch = await pending.Content.ReadFromJsonAsync<PendingBatch>(snake);

// 3. Acknowledge each with the lease it arrived with. Handle is your own code — this stub
//    only writes the id. Delivery is at-least-once, so the real one must be safe to run twice.
static Task Handle(Envelope envelope, JsonElement payload)
{
    Console.WriteLine(envelope.MessageId);
    return Task.CompletedTask;
}

foreach (var item in batch?.Items ?? [])
{
    await Handle(item.Envelope, item.Payload);
    var ack = await reviewer.PostAsJsonAsync(
        $"messages/{item.Envelope.MessageId}/ack",
        new { lease_id = item.Lease.LeaseId, outcome_summary = "Done." });
    ack.EnsureSuccessStatusCode();
}

// The get-pending response: { items: [ { envelope, payload, lease } ], … }.
record PendingBatch(List<PendingItem> Items);
record PendingItem(Envelope Envelope, JsonElement Payload, Lease Lease);
record Envelope(string MessageId, string Template);
record Lease(string LeaseId, DateTimeOffset LeaseExpiresAt);

What the samples leave out

Deliberately, so the shape stays readable. Add these before you run anything in production:

  • Retry and backoff. Honour Retry-After on 429 and 503, with jitter — on get-pending too, which answers 503 while the workspace’s audit log is behind. A maintenance window’s Retry-After can be 300 seconds, longer than the default 120-second lease. An ack refused that long finds its lease gone and the message comes back — at-least-once, which is why the handler must be safe to run twice.
  • Lease extension. If the work can outlast the lease, call extend_lease before it expires rather than discovering the loss at ack time. A lost lease is POSTMQ_LEASE_EXPIRED and the message comes back to someone else — correct behaviour, but not what you wanted.
  • nack on failure. A message you cannot handle should be declined with a redelivery time, not dropped on the floor to wait out its lease.
  • Validation. If the payload is generated, POST /v1/messages/validate runs the same checks without enqueuing anything or spending an idempotency key.
  • Error handling by code. Errors lists which codes are retryable and which are terminal.