If your support team lives inside Gmail, there is a good chance Hiver is already managing your shared inboxes. What fewer teams know is that Hiver ships a full REST API — one that lets you do everything the dashboard can do, but programmatically: assign conversations, add notes, update tags, pull analytics, and trigger workflows from any system that can make an HTTP request.
This tutorial covers the complete API surface: authentication, the key endpoints, working code in Node.js and Python, webhooks for event-driven integrations, and the patterns that make Hiver automations reliable at scale.
What is Hiver?#
Hiver is a shared inbox and helpdesk tool built directly inside Gmail. Instead of a separate support platform (Zendesk, Freshdesk, Intercom), your team manages customer emails in the same Gmail interface they already use, with Hiver adding a layer of assignment, tagging, notes, SLA tracking, and analytics on top.
For developers, the key thing to understand is that Hiver's data model maps
closely to email: every incoming message becomes a conversation, which
belongs to a mailbox (a shared email address like support@company.com),
and can be assigned to a user (an agent on your team).
The API gives you full programmatic access to that model.
Getting your API key#
API access is available on the Pro plan and above.
- Log into your Hiver account at
app.hiverhq.com. - Go to Settings → API.
- Click Generate New Key.
- Copy the key — it will only be shown once.
Store it as:
export HIVER_API_KEY="your_api_key_here"Base URL and authentication#
All Hiver API requests go to:
https://api.hiverhq.com/api/v1/Authentication is Bearer token. Add this header to every request:
Authorization: Bearer YOUR_API_KEY
Content-Type: application/jsonA minimal health check to confirm your key works:
curl -s \
-H "Authorization: Bearer $HIVER_API_KEY" \
-H "Content-Type: application/json" \
https://api.hiverhq.com/api/v1/mailboxesA 200 OK with a JSON array means you're authenticated. A 401 means the
key is wrong or hasn't been activated yet.
Core data model#
Before diving into endpoints, it helps to know how the four main objects relate to each other:
| Object | What it is |
|---|---|
| Mailbox | A shared inbox (e.g. support@company.com). All conversations belong to one. |
| Conversation | A single customer email thread. Has a status (open, pending, closed). |
| User | An agent who can be assigned conversations. |
| Note | An internal note on a conversation — visible to agents only. |
| Tag | A label you can apply to conversations for routing or reporting. |
Most API operations take a conversation_id and act on that conversation.
The mailbox_id is used for listing and filtering.
Node.js setup#
npm install node-fetch dotenv// hiver-client.js
import fetch from "node-fetch";
const BASE = "https://api.hiverhq.com/api/v1";
const KEY = process.env.HIVER_API_KEY;
export async function hiverRequest(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
...(options.headers ?? {})
}
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Hiver API ${res.status}: ${body}`);
}
return res.json();
}Python setup#
# hiver_client.py
import os
import requests
BASE = "https://api.hiverhq.com/api/v1"
HEADERS = {
"Authorization": f"Bearer {os.environ['HIVER_API_KEY']}",
"Content-Type": "application/json",
}
def hiver_request(method, path, **kwargs):
r = requests.request(method, f"{BASE}{path}", headers=HEADERS, timeout=15, **kwargs)
r.raise_for_status()
return r.json()Key endpoints#
List mailboxes#
The starting point for most integrations — get the IDs for the shared inboxes you want to interact with.
// Node.js
const { mailboxes } = await hiverRequest("/mailboxes");
for (const mb of mailboxes) {
console.log(mb.id, mb.name, mb.email);
}# Python
data = hiver_request("GET", "/mailboxes")
for mb in data["mailboxes"]:
print(mb["id"], mb["name"], mb["email"])Response shape:
{
"mailboxes": [
{
"id": "mb_01HXYZ",
"name": "Support",
"email": "support@company.com",
"type": "shared"
}
]
}List conversations#
Fetch open conversations from a mailbox. The status filter accepts
open, pending, or closed.
// Node.js — list open conversations
const { conversations } = await hiverRequest(
"/conversations?mailbox_id=mb_01HXYZ&status=open&limit=50"
);
for (const conv of conversations) {
console.log(conv.id, conv.subject, conv.assignee?.name ?? "unassigned");
}# Python — list open conversations
data = hiver_request("GET", "/conversations", params={
"mailbox_id": "mb_01HXYZ",
"status": "open",
"limit": 50,
})
for conv in data["conversations"]:
assignee = conv.get("assignee", {}).get("name", "unassigned")
print(conv["id"], conv["subject"], assignee)Assign a conversation#
Assign an open conversation to a specific agent by their user ID.
// Node.js
await hiverRequest(`/conversations/${convId}/assign`, {
method: "POST",
body: JSON.stringify({ user_id: "usr_01HABC" })
});# Python
hiver_request("POST", f"/conversations/{conv_id}/assign",
json={"user_id": "usr_01HABC"})Update conversation status#
Close a resolved conversation or reopen a previously closed one.
// Node.js — close a conversation
await hiverRequest(`/conversations/${convId}/status`, {
method: "PUT",
body: JSON.stringify({ status: "closed" })
});# Python — reopen a conversation
hiver_request("PUT", f"/conversations/{conv_id}/status",
json={"status": "open"})Valid status values: open, pending, closed.
Add an internal note#
Notes are internal — the customer never sees them. They're the standard way to leave context for teammates, flag follow-ups, or log automation actions.
// Node.js
await hiverRequest(`/conversations/${convId}/notes`, {
method: "POST",
body: JSON.stringify({
body: "Auto-assigned to billing team based on subject keyword.",
type: "internal"
})
});# Python
hiver_request("POST", f"/conversations/{conv_id}/notes", json={
"body": "Auto-assigned to billing team based on subject keyword.",
"type": "internal",
})Apply a tag#
Tags are how you categorize and route conversations. Fetch available tags first, then apply by tag ID.
// Node.js — list tags, then apply one
const { tags } = await hiverRequest("/tags");
const billingTag = tags.find(t => t.name === "billing");
await hiverRequest(`/conversations/${convId}/tags`, {
method: "POST",
body: JSON.stringify({ tag_id: billingTag.id })
});# Python
tags_data = hiver_request("GET", "/tags")
billing_tag = next(t for t in tags_data["tags"] if t["name"] == "billing")
hiver_request("POST", f"/conversations/{conv_id}/tags",
json={"tag_id": billing_tag["id"]})Pagination#
All list endpoints return a next_cursor field when more results exist.
Pass it as cursor on the next call:
// Node.js — paginate all open conversations
async function getAllOpenConversations(mailboxId) {
const results = [];
let cursor = null;
do {
const params = new URLSearchParams({
mailbox_id: mailboxId,
status: "open",
limit: "100",
...(cursor ? { cursor } : {})
});
const { conversations, next_cursor } = await hiverRequest(
`/conversations?${params}`
);
results.push(...conversations);
cursor = next_cursor ?? null;
} while (cursor);
return results;
}# Python — paginate all open conversations
def get_all_open_conversations(mailbox_id):
results = []
cursor = None
while True:
params = {"mailbox_id": mailbox_id, "status": "open", "limit": 100}
if cursor:
params["cursor"] = cursor
data = hiver_request("GET", "/conversations", params=params)
results.extend(data["conversations"])
cursor = data.get("next_cursor")
if not cursor:
break
return resultsWebhooks#
Polling is wasteful for real-time needs. Hiver webhooks push events to your endpoint the moment something happens.
Setting up a webhook#
- Go to Settings → Integrations → Webhooks.
- Click Add Webhook.
- Enter your endpoint URL (must be publicly reachable over HTTPS).
- Select the events you want:
conversation.created,conversation.assigned,conversation.status_changed,note.added, and others. - Save. Hiver will send a test ping to verify the URL.
Handling webhook payloads#
// Node.js — Express webhook handler
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/hiver", (req, res) => {
const { event, data } = req.body;
switch (event) {
case "conversation.created":
console.log("New conversation:", data.conversation.id, data.conversation.subject);
// Auto-tag, auto-assign, etc.
break;
case "conversation.assigned":
console.log(
`Assigned ${data.conversation.id} to ${data.assignee.name}`
);
break;
case "conversation.status_changed":
if (data.status === "closed") {
console.log("Conversation closed:", data.conversation.id);
// Trigger post-resolution workflow
}
break;
}
res.sendStatus(200);
});
app.listen(3000);# Python — Flask webhook handler
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/webhooks/hiver", methods=["POST"])
def hiver_webhook():
payload = request.get_json()
event = payload.get("event")
data = payload.get("data", {})
if event == "conversation.created":
conv = data.get("conversation", {})
print(f"New: {conv.get('id')} — {conv.get('subject')}")
elif event == "conversation.status_changed":
if data.get("status") == "closed":
print(f"Closed: {data['conversation']['id']}")
return jsonify({"ok": True}), 200Real-world pattern: auto-assign by keyword#
Here's a complete example that wires together several endpoints — pull open unassigned conversations, detect keywords in the subject line, tag and assign them to the right team member, and leave a note explaining why.
// auto-assign.js — run on a schedule (e.g. every 5 minutes)
import "dotenv/config";
import { hiverRequest } from "./hiver-client.js";
const RULES = [
{ keyword: "invoice", tag: "billing", assignee: "usr_01BILLING" },
{ keyword: "refund", tag: "billing", assignee: "usr_01BILLING" },
{ keyword: "login", tag: "technical", assignee: "usr_01TECH" },
{ keyword: "password", tag: "technical", assignee: "usr_01TECH" },
];
async function getTagId(name) {
const { tags } = await hiverRequest("/tags");
return tags.find(t => t.name === name)?.id;
}
async function autoAssign(mailboxId) {
const tagIds = Object.fromEntries(
await Promise.all(
[...new Set(RULES.map(r => r.tag))].map(async name => [name, await getTagId(name)])
)
);
const { conversations } = await hiverRequest(
`/conversations?mailbox_id=${mailboxId}&status=open&limit=100`
);
const unassigned = conversations.filter(c => !c.assignee);
for (const conv of unassigned) {
const subject = (conv.subject ?? "").toLowerCase();
const rule = RULES.find(r => subject.includes(r.keyword));
if (!rule) continue;
await Promise.all([
hiverRequest(`/conversations/${conv.id}/assign`, {
method: "POST",
body: JSON.stringify({ user_id: rule.assignee })
}),
hiverRequest(`/conversations/${conv.id}/tags`, {
method: "POST",
body: JSON.stringify({ tag_id: tagIds[rule.tag] })
}),
hiverRequest(`/conversations/${conv.id}/notes`, {
method: "POST",
body: JSON.stringify({
body: `Auto-assigned: subject matched keyword "${rule.keyword}".`,
type: "internal"
})
})
]);
console.log(`Assigned ${conv.id} (${conv.subject}) → ${rule.assignee}`);
}
}
autoAssign("mb_01HXYZ");This pattern runs well as a cron job or a serverless function triggered on a
schedule. For lower latency, replace the polling loop with a webhook on
conversation.created and run the same assignment logic on each event.
API reference summary#
| Endpoint | Method | What it does |
|---|---|---|
/mailboxes | GET | List all mailboxes you have access to |
/conversations | GET | List conversations (filter by mailbox, status, tag) |
/conversations/:id | GET | Fetch a single conversation |
/conversations/:id/assign | POST | Assign to a user |
/conversations/:id/status | PUT | Update status (open / pending / closed) |
/conversations/:id/notes | POST | Add an internal note |
/conversations/:id/tags | POST | Apply a tag |
/conversations/:id/tags/:tagId | DELETE | Remove a tag |
/users | GET | List agents on your account |
/tags | GET | List all tags |
/tags | POST | Create a new tag |
Error handling#
The Hiver API uses standard HTTP status codes:
| Code | Meaning | What to do |
|---|---|---|
200 | Success | — |
400 | Bad request | Check request body shape and required fields |
401 | Unauthorized | API key missing, wrong, or expired |
403 | Forbidden | Key doesn't have access to this mailbox or resource |
404 | Not found | Conversation or resource ID doesn't exist |
429 | Rate limited | Back off — respect the Retry-After header |
500 | Server error | Retry once with exponential backoff |
A minimal retry wrapper for 429 and 5xx:
// Node.js — retry with backoff
async function hiverRequestWithRetry(path, options = {}, retries = 3) {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(`https://api.hiverhq.com/api/v1${path}`, {
...options,
headers: {
"Authorization": `Bearer ${process.env.HIVER_API_KEY}`,
"Content-Type": "application/json",
...(options.headers ?? {})
}
});
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "5", 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
if (res.status >= 500 && attempt < retries - 1) {
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
continue;
}
if (!res.ok) throw new Error(`Hiver API ${res.status}`);
return res.json();
}
}What you can build#
Pros
Cons
Related reading#
- Best Email APIs for Developers — if you need transactional email alongside your helpdesk integration.
- Twilio SMS API Tutorial — adding SMS notifications to your support workflow.
- Browse all Communication APIs — email, SMS, voice, and helpdesk APIs.