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.

Deactivation does not revoke a credential that an agent already holds. Deactivation frees the cap slot and stops new mints. A credential that is already on a machine stays valid until it expires. To stop a machine sooner, refuse its connection at your own server.

Vocabulary

TermMeaning
AppYour product, registered on bellhop.dev. It owns a key pair, a plan, and a set of agents.
AgentOne installed copy of the Bellhop application, paired to one app at one location. This is the unit that bellhop.dev counts and licenses.
CredentialA JWT with an Ed25519 signature. It authorizes one agent for a long time. The library gets it, stores it, and renews it.
EntitlementsWhat the agent can do: scales on or off, the printer count, and whether Bellhop branding shows.
Agent capThe 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.

KeyShapeHandling
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.

NodeWhat 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.
RailsWhat 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
Adding a location is not idempotent. If you call it two times, you get two agents and you use two cap slots. Call it one time, in the same transaction that creates your own location record.

Print

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.

NodeWhen 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.
RailsWhen 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." }
EndpointWhat it doesThe library calls it
POST /api/v1/agentsCreates 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/activateMints a credential, bound to your public host. The body is { "server_host": "deliver.example.com" }.when a machine pairs
POST /api/v1/agents/:id/renewThe same request and response as activate. It stamps last_renewed_at.never; the library does this itself
POST /api/v1/agents/:id/deactivateFrees the cap slot. No body. Idempotent, and permitted when the app is not in good standing.when you decommission
GET /api/v1/agentsEvery 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/appYour 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.jsonThe 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.