Integration guide
Add Bellhop to your web app
Bellhop is an agent for Mac, Windows and Linux. It gives your web application direct access to the label printers and USB scales at a location. Your server sends a print job to the agent. The label comes out.
This page is the library API: the calls you write. A library does the protocol, both transports, the pairing, the job redelivery, the credential renewal, and the calls to bellhop.dev. If your language has no library, read the wire protocol instead.
- Libraries
- Node · Rails
- Formats
- ZPL · raw · PDF · GIF · scale readings
- Base URL
- https://bellhop.dev
- Version
- v1
- Reviewed
- 4 Sep 2026
How the pieces fit
There are three parties. bellhop.dev does the least of the three.
- Your server is the only part that speaks to bellhop.dev. It keeps the secret key. The library makes these calls for you.
- The Bellhop agent runs on a Mac, Windows or Linux machine at a physical location. It connects out to your server. It holds one credential and checks it offline. It does not listen on a port, so nobody has to ask for a firewall exception.
- bellhop.dev signs credentials and counts agents. It never receives a print job or a scale reading. No protocol message passes through it.
The third point controls the design. The agent checks its credential offline against a bundled public key set and applies the entitlements locally. So the credential signature is the only control point. There is no revocation call and no check at run time.
Vocabulary
| Term | Meaning |
|---|---|
| App | Your product, registered on bellhop.dev. It owns a key pair, a plan, and a set of agents. |
| Agent | One installed copy of Bellhop, paired to one app at one location. This is the unit that bellhop.dev counts and licenses. |
| Credential | A JWT with an Ed25519 signature. It authorizes one agent for a long time. The library gets it and keeps it renewed. |
| Entitlements | What the agent can do: scales on or off, the printer count, and whether Bellhop branding shows. |
| Agent cap | The maximum number of agents your app can have that are not deactivated. bellhop.dev checks it when you add a location, and at no other time. |
Getting your keys
Sign in. There is no password: we send a link to your inbox. Create an app. You then have two keys. The library needs only the secret key.
If you have no account yet, create one: it takes an email address and the link we send to it. Only this step needs an account; the rest of the guide reads the same without one.
| Key | Shape | Handling |
|---|---|---|
| Publishable | bh_pk_ + 24 chars |
It identifies your app. It is safe to ship. It never changes. |
| Secret | bh_sk_ + 32 chars |
The library authenticates with it. We show it one time, when you create it and after each rotation. Keep it in your server's encrypted credentials. |
The prefixes are important. Match bh_sk_ in your log
filter. A secret key must never reach a log line or an error report.
A rotation takes effect immediately. There is no grace period. The old key fails at its next use. Deploy the new key first, or accept a short time of failures.
Install a library
npm install @usefae/bellhop-node ws
There are adapters for Express and for Fastify.
The library also works with any host that uses fetch.
Mount the adapter, then attach the WebSocket to your HTTP server.
import { Bellhop } from '@usefae/bellhop-node'
import { bellhopExpress } from '@usefae/bellhop-node/express'
import { attachWebSocket } from '@usefae/bellhop-node/ws'
import { sqliteStore } from '@usefae/bellhop-node/sqlite'
export const bellhop = new Bellhop({
secretKey: process.env.BELLHOP_SECRET_KEY,
publicUrl: 'https://deliver.example.com',
store: sqliteStore('bellhop.db'),
})
app.use(bellhopExpress(bellhop))
const server = app.listen(3000)
attachWebSocket(bellhop, server)
The store keeps your records. Use sqliteStore, or
write your own against the Store interface. The
default is memoryStore(), which does not survive a
restart.
bundle add bellhop-rails bin/rails generate bellhop:install bin/rails db:migrate
The generator writes the initializer and the migration, then mounts the engine. Your own database keeps the records, so there is no store to choose.
The gem is bellhop-rails on RubyGems. Its source, changelog, and issue tracker are on GitHub.
Configure it
Two settings are necessary. Everything else has a default that is correct for most apps.
| Node | What it does |
|---|---|
secretKey
|
Necessary. Your app's secret key. |
publicUrl
|
Necessary. Your public base URL. See the warning below. |
store
|
Where records live. Rails keeps them in your own database. |
apiUrl
|
The licensing API. |
appName
|
What the agent shows while it is idle. bellhop.dev supplies this after the first activation, so it is only a fallback. |
accentColor
|
As above. |
basePath
|
Where the routes live. The default is /bellhop. In Rails this is the engine mount. |
heartbeatSeconds
|
How often the agent checks the connection. The agent limits this to between 5 and 120. |
pollSeconds
|
How long the HTTP transport holds a request open. Set it to 0 on a platform that cannot hold one. |
claimTtlMs
|
How long a pairing link works. |
renewWithinDays
|
How far before expiry to renew. The default is right for almost everyone, and earlier is always safe. |
autoRenew
|
Whether the library renews on its own. On by default; turn it off only if your own job runner calls renew. |
| Rails | What it does |
|---|---|
secret_key
|
Necessary. Your app's secret key. |
public_url
|
Necessary. Your public base URL. See the warning below. |
| — | Where records live. Rails keeps them in your own database. |
api_url
|
The licensing API. |
app_name
|
What the agent shows while it is idle. bellhop.dev supplies this after the first activation, so it is only a fallback. |
accent_color
|
As above. |
| — | Where the routes live. The default is /bellhop. In Rails this is the engine mount. |
heartbeat_seconds
|
How often the agent checks the connection. The agent limits this to between 5 and 120. |
poll_seconds
|
How long the HTTP transport holds a request open. Set it to 0 on a platform that cannot hold one. |
claim_ttl
|
How long a pairing link works. |
renew_within
|
How far before expiry to renew. The default is right for almost everyone, and earlier is always safe. |
auto_renew
|
Whether the library renews on its own. On by default; turn it off only if your own job runner calls renew. |
const bellhop = new Bellhop({
secretKey: process.env.BELLHOP_SECRET_KEY,
publicUrl: 'https://deliver.example.com',
store: sqliteStore('bellhop.db'),
})
# config/initializers/bellhop.rb Bellhop.configure do |config| config.secret_key = Rails.application.credentials.dig(:bellhop, :secret_key) config.public_url = "https://deliver.example.com" end
The one setting that matters
The host in your public URL is the pairing host. It goes into every pairing link, and the credential is bound to it; the agent compares the two byte for byte. Choose it one time. If you change it after agents pair, all of them pair again. Take it from configuration, never from a request header. Your socket can use a different host.
Add a location
One call adds a location. It creates the agent on bellhop.dev and stores your record, then returns a pairing link. Show that link to the person at the desk. They open it. The agent claims it, and the location is live.
const { pairingLink } = await bellhop.agents.create({ label: 'Shipping Desk' })
A pairing link works one time, then expires. To pair a replacement machine, ask for a new link. This mints a new credential at no cost.
bellhop.agents.repair(id) // a new link for the same location bellhop.agents.isOnline(id) // is that machine connected right now bellhop.agents.remove(id) // the location closed; this frees the cap slot bellhop.agents.list() // every location you added bellhop.agents.onlineIds() // the ones connected right now
agent = Bellhop::Agent.provision(label: "Shipping Desk") agent.pairing_link
A pairing link works one time, then expires. To pair a replacement machine, ask for a new link. This mints a new credential at no cost.
agent.repair! # a new link for the same location agent.paired? # has a machine claimed it agent.online? # is that machine connected right now agent.decommission! # the location closed; this frees the cap slot
await bellhop.print(agentId, { kind: 'label', format: 'zpl', data: zpl })
agent.print(kind: "label", format: :zpl, data: zpl)
kind is yours; the agent does not read it. Use it in your own admin screens. format is
zpl, raw,
pdf, or gif. Use
raw for bytes the agent must not touch, for example
ESC/POS for a receipt printer. A job that names no printer goes to the location's
default printer for its format.
Send a large document by URL instead of inline. The block runs after the job exists, so the signature can name it.
agent.print(kind: "packing_slip", format: :pdf) { |job| document_url(job, sig: sign(job)) }
Send a large document by URL instead of inline.
await bellhop.print(agentId, { kind: 'packing_slip', format: 'pdf', url: signedUrl })
The agent reports its shared printers when it connects: an id, a display name, and
what each one can do. To print on one specific printer, send its id as
printer. To ask for something specific, send
options: copies, duplex, paper, bin, dpi, color,
pages, rotate, fit, collate, and nup.
await bellhop.print(agentId, {
kind: 'packing_slip', format: 'pdf', url: signedUrl,
printer: 'Office_HP', options: { copies: 2, duplex: 'long-edge' },
})
agent.print(kind: "packing_slip", format: :pdf, data: pdf,
printer: "Office_HP", options: { copies: 2, duplex: "long-edge" })
An option is a firm request. If the printer cannot do what an option asks, the whole job fails with a clear error code. Nothing prints. The agent never prints a reduced version of the job.
If a location is offline, the job waits. It goes out at the next connection. This is normal: machines sleep overnight. The library re-sends any job that nobody acknowledged, and the agent drops the duplicates.
The library refuses some jobs at the call site, because each fails in a way that is
hard to trace from the other end: a format that the
location did not advertise, an inline document above 50 MB, and an option that is
unknown, malformed, or not applicable to the job's format. Only the agent can check an
option against the printer itself, and its answer comes back in the ack.
agent.supports?(:zpl) # what this location advertised agent.outstanding_jobs # sent, and not yet acknowledged bellhop.jobs.get(id) # Node bellhop.jobs.recent(20) bellhop.jobs.outstanding(agentId)
Hear back from the hardware
This is the half of an integration that is yours. The library tells you what happened, and your app decides what that means.
| Node | When it fires |
|---|---|
weight |
A scale reports a stable reading. |
ack |
A print job finished, or it failed. |
hello |
A location connected and said what it can do. |
print |
Your server sent a job. |
event |
The agent reported a state change, for example a printer that ran out of paper. |
online / offline |
A location connected, or its connection ended. |
error |
The library caught something you should see. Subscribe to this one. |
| Rails | When it fires |
|---|---|
weight.bellhop |
A scale reports a stable reading. |
ack.bellhop |
A print job finished, or it failed. |
hello.bellhop |
A location connected and said what it can do. |
print.bellhop |
Your server sent a job. |
event.bellhop |
The agent reported a state change, for example a printer that ran out of paper. |
bellhop.on('weight', ({ agentId, grams }) => fillShippingForm(agentId, grams))
bellhop.on('ack', ({ jobId, status, error }) => recordOutcome(jobId, status, error))
bellhop.on('error', (error) => report(error))
ActiveSupport::Notifications.subscribe("weight.bellhop") do |event|
ShippingForm.fill(event.payload[:agent], grams: event.payload[:grams])
end
ActiveSupport::Notifications.subscribe("ack.bellhop") do |event|
job = event.payload[:job]
Rails.logger.warn("#{job.id} failed: #{job.error}") if job.status == "failed"
end
A weight is the useful one. A shipping form that fills itself while the parcel is on the scale is the reason most apps add Bellhop.
Keep credentials fresh
Nothing to do here. A credential lasts a long time. The library renews it shortly before expiry and sends the new one to the agent. Nobody visits the desk. Nothing needs scheduling.
If you would rather renew from your own job runner, turn the automation off and make the one call yourself:
await bellhop.renew() // with autoRenew: false
Bellhop.renew! # with config.auto_renew = false
Plan changes, without the wait
Entitlements are read when a credential is minted, so on its own a plan change waits
for the next renewal. Register a webhook URL on your app's page and it stops
waiting. When your plan changes, Bellhop calls the URL; the library re-mints every
credential and pushes the new ones to your agents within moments. When an agent is
deactivated on bellhop.dev, the same call has the library retire it locally, exactly
as if you had removed it from your own admin.
Both libraries answer the webhook at /bellhop/webhook with nothing to configure.
Each delivery is signed with the same published keys that sign credentials.
A delivery that cannot be verified changes nothing.
Check your setup
Both libraries have a doctor. It checks the secret key, the public URL, the store, and the routes, then tells you which one is wrong. Run it after you install, and again after you deploy.
npx bellhop doctor // from a terminal await bellhop.doctor() // or from code
bin/rails bellhop:doctor
To test a printer without a real order, print a test label.
agent.print(kind: "test", format: :zpl, data: Bellhop.test_label)
Errors
Both libraries use the same three names.
ConfigurationError means a setting is wrong.
LicensingError means bellhop.dev refused the call.
AgentError means the location cannot take the job. In
Rails all three descend from Bellhop::Error, so you can
rescue the one class. Each error carries a machine-readable code from the table
below. Branch on the code, never on the sentence.
| Status | Code | Do this |
|---|---|---|
| 401 |
invalid_secret_key
|
Stop, and tell an operator. The key is wrong, or somebody rotated it. Do not try again. |
| 402 |
payment_required
|
Billing is past the grace period. Show this to the account owner. A retry does not help until somebody pays, and existing agents work until their credentials expire. |
| 404 |
agent_not_found
|
Your stored record is old, or it belongs to another app. |
| 409 |
agent_deactivated
|
This is final for this location. Add the location again to get a new agent. |
| 422 |
invalid_label
|
Check the label in your own form before you call. |
| 422 |
agent_limit_reached
|
Tell the admin that the app is at the cap, and show them the limit from the body. If they decommission a location that nobody uses, a slot becomes free immediately. |
| 422 |
invalid_server_host
|
Your public URL is wrong. Send a bare hostname, and an optional port. |
| 500 |
internal_error
|
Try again, and increase the wait between attempts. The library already does this for the calls it makes on its own. |
Only 500 is safe to retry without thought. Each 4xx above needs a decision from you; a retry will not clear it.
The licensing API
You probably do not need this section
The library makes every call below for you.
Read this if you are building an admin screen against GET /api/v1/app, or if you want to see the shapes and try a call by hand.
Each request carries the secret key as a bearer token. The API sends no CORS headers and never will. A call from a browser cannot work; it would also show your secret key to every visitor. Make each call from your server.
Authorization: Bearer bh_sk_... Content-Type: application/json
Each timestamp is ISO 8601 in UTC. Each error body has the same shape, and each endpoint
can also return 401
invalid_secret_key.
{ "error": "machine_readable_code", "message": "Human sentence." }
| Endpoint | What it does | The library calls it |
|---|---|---|
POST /api/v1/agents | Creates an agent. The body is { "label": "Toronto Office" }. This is where the cap is enforced. | when you add a location |
POST /api/v1/agents/:id/activate | Mints a credential, bound to your public host. The body is { "server_host": "deliver.example.com" }. | when a machine pairs |
POST /api/v1/agents/:id/renew | The same request and response as activate. It stamps last_renewed_at. | never; the library does this itself |
POST /api/v1/agents/:id/deactivate | Frees the cap slot. No body. Idempotent, and permitted when the app is not in good standing. | when you decommission |
GET /api/v1/agents | Every agent for this app, in id order, deactivated ones included. There are no pages. | never; this one is yours |
GET /api/v1/app | Your plan, entitlements, and agent count. Use it for an admin screen, or as a health check. | never; this one is yours |
GET /.well-known/bellhop-keys.json | The public key set. Unauthenticated. The agent bundles these into its build to check a credential offline. | never; the agent uses it |
An activation returns the credential, its expiry, a serial, and the branding that the agent shows while it is idle.
{
"credential": "eyJraWQiOiIyMDI2LTA4IiwiYWxnIjoiRWREU0EifQ...",
"expires_at": "2027-08-06T23:05:35Z",
"serial": "56c2b7ba-7cce-4a7f-b967-a93a59f3ad25",
"branding": {
"app_name": "Slaydate",
"accent_color": "#B91C1C",
"icon_base64": "iVBORw0KGgoAAAANSUhEUg...",
"show_bellhop_branding": true
}
}
GET /api/v1/app is the one an admin screen wants.
active_agent_count counts each agent that is not
deactivated, so it is the number that the cap is measured against.
max_printers: null means unlimited.
{
"name": "Slaydate",
"publishable_key": "bh_pk_...",
"plan": "fleet",
"entitlements": {
"agent_cap": 250,
"scales_allowed": true,
"max_printers": null,
"remove_branding": true
},
"active_agent_count": 34,
"in_good_standing": true
}
By hand, with curl
SK="bh_sk_..."
# Look at the app.
curl -s -H "Authorization: Bearer $SK" \
https://bellhop.dev/api/v1/app
# An admin adds a location.
curl -s -X POST -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
-d '{"label":"Toronto Office"}' \
https://bellhop.dev/api/v1/agents
# => {"id":42,"label":"Toronto Office","status":"pending"}
# A person pairs a machine there.
curl -s -X POST -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
-d '{"server_host":"deliver.example.com"}' \
https://bellhop.dev/api/v1/agents/42/activate
# Much later, in a background job.
curl -s -X POST -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
-d '{"server_host":"deliver.example.com"}' \
https://bellhop.dev/api/v1/agents/42/renew
# The location closes.
curl -s -X POST -H "Authorization: Bearer $SK" \
https://bellhop.dev/api/v1/agents/42/deactivate
Rules worth repeating
- The secret key stays on your server. It never enters a log or a browser.
- Your public URL comes from configuration, never from a request header. It must match the host in your pairing links byte for byte, non-default port included.
- Adding a location is not idempotent. Do it one time, and store your record in the same transaction.
- Decommissioning is final. It does not revoke a credential that the machine already holds, so refuse the connection if you must stop a machine immediately.
- An offline location is normal. A job waits, then goes out at the next connection.
- Subscribe to the error event. It is where the library tells you about the failures you cannot see.
- Run the doctor after every deploy, as well as after you install.
If you write a client by hand, the wire protocol page has the rules that apply to it.