> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blujay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Examples

> Complete, copy-paste programs for the most common Blujay integrations.

Each example is a single file you can run with Node.js 18 or later. They are Node only; the API is plain HTTPS and JSON, so the same calls work from any language, and the [API reference](/api-reference/introduction.md) shows each request in cURL, JavaScript, Python, Go, Ruby and PHP. Set `BLUJAY_API_KEY` (and, for webhooks, `BLUJAY_WEBHOOK_SECRET`) in your environment first.

## Reply to incoming messages

A webhook receiver that verifies each event and answers a new direct message with an acknowledgement. Subscribe it to `message.received`.

It sends the reply before it answers the webhook. If the send fails with a retryable error, the receiver returns `503`, so Blujay delivers the event again later (up to 10 attempts). Using the event ID as the `Idempotency-Key` means a redelivered event never sends a second reply.

Acknowledge once per conversation, not once per message: the example remembers the last acknowledgement per chat and stays quiet for 12 hours after it, so a contact's "thanks" doesn't get another acknowledgement. Every acknowledgement also counts toward the [6 texts in a row](/messaging/sending-rules.md) the contact must respond to.

```javascript reply-bot.mjs theme={null}
import crypto from "node:crypto";
import express from "express";

const API = "https://api.blujay.io/v1";
const KEY = process.env.BLUJAY_API_KEY;
const SECRET = Buffer.from(process.env.BLUJAY_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const QUIET_MS = 12 * 60 * 60 * 1000; // one acknowledgement per chat per 12 hours
const lastAckByChat = new Map(); // use your database in production

function verified(headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigs = headers["webhook-signature"];
  if (!id || !ts || !sigs) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(`${id}.${ts}.${rawBody}`).digest();
  return sigs.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();
app.post("/webhooks/blujay", express.raw({ type: "application/json" }), async (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verified(req.headers, rawBody)) return res.status(400).end();

  const event = JSON.parse(rawBody);
  if (event.event_type !== "message.received" || !event.data.contact_id) {
    return res.status(200).end(); // nothing to do; direct chats only
  }

  const chatId = event.data.chat_id;
  const lastAck = lastAckByChat.get(chatId) ?? 0;
  if (Date.now() - lastAck < QUIET_MS) {
    return res.status(200).end(); // already acknowledged this conversation recently
  }

  try {
    const response = await fetch(`${API}/chats/${chatId}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `ack-${event.event_id}`,
      },
      body: JSON.stringify({
        message: { parts: [{ type: "text", text: "Thanks, we got your message and will reply shortly." }] },
      }),
      signal: AbortSignal.timeout(8000), // answer the webhook within its 10-second limit
    });
    const body = await response.json();
    if (body.status === "success") {
      lastAckByChat.set(chatId, Date.now());
      return res.status(200).end();
    }
    if (body.error.retryable) return res.status(503).end(); // Blujay redelivers; same key, no duplicate
    console.error("Reply not sent:", body.error.code, body.trace_id); // e.g. RECIPIENT_SUPPRESSED, PRE_REPLY_LIMIT_REACHED
    return res.status(200).end();
  } catch {
    return res.status(503).end(); // timeout or network error: let Blujay redeliver
  }
});

app.listen(3000, () => console.log("Listening on :3000"));
```

For anything slower than a single API call, store the event in your own database first, acknowledge, and do the work from a job queue that retries. See [Webhooks](/webhooks/overview.md) and [Receive messages](/messaging/receive-messages.md).

## Send a batch safely

Sends a first message to a list of people, one at a time, from the first number that can send. Before each send it checks the number's live `limits.new_contacts.remaining_today` and `queue.remaining` and stops when either reaches zero, and it also stops on a `429`. Each recipient's idempotency key makes the script safe to run again: finished sends are never repeated.

```javascript batch-send.mjs theme={null}
const API = "https://api.blujay.io/v1";
const headers = { Authorization: `Bearer ${process.env.BLUJAY_API_KEY}`, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const recipients = [
  { phone: "+15555550123", name: "Ava" },
  { phone: "+15555550124", name: "Jordan" },
];

async function getSender() {
  const body = await (await fetch(`${API}/phone-numbers`, { headers })).json();
  if (body.status === "error") throw new Error(`${body.error.code} (trace ${body.trace_id})`);
  return body.data.phone_numbers.find((n) => n.status === "active" && n.send_status.allowed);
}

function hasRoom(sender) {
  if (!sender.limits) return true; // Free Plan numbers don't report limits
  return sender.limits.new_contacts.remaining_today > 0 && sender.queue.remaining > 0;
}

let sender = await getSender();
if (!sender) throw new Error("No phone number can send right now");

for (const person of recipients) {
  sender = await getSender(); // re-read: other sends use up room too
  if (!sender || !hasRoom(sender)) {
    console.log("No room left today; run again after", sender?.limits?.new_contacts.reset_at ?? "the number can send again");
    break;
  }

  const response = await fetch(`${API}/chats`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `welcome-2026-10-14-${person.phone}` },
    body: JSON.stringify({
      from: sender.display_number,
      to: [person.phone],
      message: { parts: [{ type: "text", text: `Hi ${person.name}, thanks for signing up. Any questions so far?` }] },
    }),
  });
  const body = await response.json();

  if (body.status === "success") {
    console.log(person.phone, "queued", body.data.message.id);
  } else if (body.error.code === "RATE_LIMIT_EXCEEDED" || body.error.code === "QUEUE_FULL") {
    console.log("Number is at its limit; run again after", body.error.details[0].reset_at);
    break;
  } else if (body.error.retryable) {
    console.log("Temporary error", body.error.code, "- run the script again; finished sends won't repeat");
    break;
  } else {
    console.log(person.phone, "skipped:", body.error.code); // e.g. RECIPIENT_SUPPRESSED, CONTACT_NOT_VERIFIED
  }
  await sleep(2000); // spread sends out
}
```

See [Sending rules](/messaging/sending-rules.md) and [Sending best practices](/best-practices.md) before sending to many people.

## Next steps

> **[Verify signatures](/webhooks/verify-signatures.md)**
>
> The signature check used above, explained.

> **[Phone numbers and limits](/phone-numbers.md)**
>
> The live fields the batch script reads.

> **[Build with AI](/build-with-ai.md)**
>
> Have your assistant review your integration.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.