Integration guide
Add Bellhop to your web app
Bellhop is a Windows and Mac application. 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 application, and 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
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 is a Windows or Mac application at a physical location. It connects out to your server. It holds one credential, and it checks that credential 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, a scale reading, or a protocol message.
The third point controls the design. The agent checks its credential offline against a bundled public key set, and it applies the entitlements locally. The credential signature is therefore the only control point. There is no revocation call, and there is 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 the Bellhop application, 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, stores it, and renews it. |
| 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 the secret key, and nothing else.
| Key | Shape | Handling |
|---|---|---|
| Publishable | bh_pk_ + 24 chars |
It identifies your app. It is safe to ship. It is permanent, and we never rotate it. |
| 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, and 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 @bellhop/node
There are adapters for Express and for Fastify, and the library works with any host
that uses fetch. Mount the adapter, and attach the
WebSocket to your HTTP server.
import { Bellhop } from '@bellhop/node'
import { bellhopExpress } from '@bellhop/node/express'
import { attachWebSocket } from '@bellhop/node/ws'
import { sqliteStore } from '@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, and it mounts the engine. Your own database keeps the records, so there is no store to choose.
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, the credential is bound to it, and the agent compares it byte for byte. Choose it one time. If you change it after agents pair, all of them pair again. Take it from configuration, and 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, it stores your record, and it returns a pairing link. Show that link to the person at the desk. They open it, the Bellhop application claims it, and the location is live.
const { pairingLink } = await bellhop.agents.create({ label: 'Shipping Desk' })
A pairing link works one time, and it expires. To pair a replacement machine, ask for a new link. This mints a new credential and it costs nothing.
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, and it expires. To pair a replacement machine, ask for a new link. This mints a new credential and it costs nothing.
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, and 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 request, and not a hint. If the printer cannot do what an option asks, the whole job fails with a clear error code, and nothing prints. The agent does not print a reduced version of the job.
If a location is offline, the job waits and it goes out at the next connection. This is normal, and it is not an error: machines sleep overnight. The library re-sends a job that nobody acknowledged, and the agent removes 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, and the library renews it shortly before expiry and sends the new one to the agent. Nobody visits the desk, and 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 and the library re-mints every credential and
pushes them 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, and 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, and it 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 the machine-readable code below, so you branch
on the code and not 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 is a decision, and not a temporary fault.
The licensing API
You probably do not need this section
The library makes every call below for you. Read this if you want to see the shapes, if
you are checking your secret key by hand, or if you build an admin screen against
GET /api/v1/app.
Each request carries the secret key as a bearer token. The API sends no CORS headers, and it never will. A call from a browser cannot work, and it would 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, and this includes the deactivated ones. 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, and it never reaches a browser.
- Your public URL comes from configuration, and never from a request header. It must match the host in your pairing links byte for byte, and this includes a port that is not the default.
- Adding a location is not idempotent. Do it one time, and store your record in the same transaction.
- Decommissioning is final, and it does not revoke a credential that the machine already holds. Refuse the connection if you must stop a machine immediately.
- An offline location is normal. A job waits, and it 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 you deploy, and not only after you install.
If you write a client by hand, the wire protocol page has the rules that apply to it.