Jobber API Ruby client

A client for the Jobber GraphQL API. It needs the standard library and two files of Active Support, to tell a field Jobber answered empty from one it never answered at all.

Available methods

Credentials

Generate the URL for Jobber users to authorize the app:

url = Jbr.oauth_url_for redirect_uri:, state:
url # => 'https://api.getjobber.com/api/oauth/authorize?state=...&redirect_uri=...'

Create credentials with a code and a redirect URI:

oauth = Jbr.create_oauth code:, redirect_uri:

Initialize with existing credentials:

oauth = Jbr.oauth_for access_token:, refresh_token:, expires_at:, account_id:

Access OAuth attributes:

oauth.access_token # => 'eyJhbGciOiJIUzI1NiJ'
oauth.refresh_token # => 'ea02775958c5fca28d'
oauth.expires_at # => 2026-05-22 14:32:53
oauth. # => 'Z2lkOi8vSm9iYmV'

Revoke credentials:

oauth.delete

Credentials go bad only when Jobber says so. A refused refresh — the invalid_grant Jobber names — sets invalid_at and answers queries with nothing. Anything else that goes wrong, including a 500 or a rate limit, raises Jbr::Error instead, because a token that may still work is worth more than a tidy failure:

oauth.invalid_at # => 2026-08-13 11:02:41, or nil while the credentials are good
oauth.query '{ ok }' # => {} once they are refused, raises Jbr::Error where Jobber had trouble

Requests

Create a Jobber request, finding or creating a Client with a matching phone number:

request = oauth.requests.create first_name: 'Jane', last_name: 'Doe', phone: '5553335555',
  email: 'jane@example.com', title: 'New Plumber Lead', instructions: 'Needs new faucet'
request.id # => 'Z2lkOi8vSm9iYmVyL'
request.client_id # => 'MwMTU0Mg'

Quotes

Fetch a quote from Jobber:

quote = oauth.quotes.find 'Z2lkOi8vS'
quote.id # => 'Z2lkOi8vS'
quote.request_id # => 'Z2lkOi8vSm9iYmVyL'

Jobs

Fetch a job from Jobber by the ID it is filed under:

job = oauth.jobs.find 'Njc5MTk5'
job.id # => 'Z2lkOi8vS'
job.quote_id # => 'Z2lkOi8vS'
job.scheduled_at # => 2026-05-14 23:02:52
job.completed_at # => 2026-05-18 11:36:13

Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only once the page before it runs out, so first costs one request where to_a costs as many as the account has pages. A walk paces itself, so a long one is never refused: see Rate limits.

jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
oauth.jobs.past # => an Enumerator of the ones dated before now
oauth.jobs.upcoming # => an Enumerator of the ones dated from now on

job = jobs.first
job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
job.title # => 'Furnace tune-up'
job.instructions # => 'Ring the doorbell twice'
job.status # => 'requires_invoicing'
job.total # => 260.0
job.quote_total # => 240.0
job.created_at # => 2026-05-10 09:15:00

Line items

What the work actually was, where the title is only what somebody called it. Asked for the same way as anything nested, since a page costs what it carries:

job = oauth.jobs.includes(:line_items).find 'Njc5MTk5'

job.summary # => '3 Bathroom Faucet Installation and 2 Change Toilet Valve', the lines as a
            #    sentence of how many of what. Falls back to #name where there are none

job.line_items # => an Array of the lines the job is made of
line = job.line_items.first
line.quantity # => 3, whole where Jobber's own Float has nothing after the point, and 3.5
              #    where it has: `3 Faucets`, or `3.5 Hours` for what was really billed
line.quantified # => '3 Bathroom Faucet Installation', how many of what
line.name # => 'Bathroom Faucet Installation'
line.description # => 'Professional installation of a new bathroom faucet'
line.to_s # => '3 Bathroom Faucet Installation (Professional installation of a new bathroom
          #     faucet)', and without the parenthesis where nobody described it

Every line Jobber holds is in the list, up to twenty of them, in the order it holds them and whatever each is quantified at. One it holds no quantity for reads as its name alone. Ask for nothing and nothing arrives, so oauth.jobs.first.line_items is empty where the query never named them.

Invoices

Fetch a non-draft invoice from Jobber:

invoice = oauth.invoices.find 'MjU3ODA0'
invoice.id # => 'MjU3ODA0'
invoice.job_id # => 'Z2lkOi8vS'
invoice.total # => '40.30'
invoice.issued_at # => 2026-05-22 12:12:53
invoice.completed_at # => 2026-05-22 14:32:53

Visits

Walk the account's visits, oldest first, the same way as its jobs:

visits = oauth.visits # => an Enumerable of every visit, nothing fetched yet
oauth.visits.upcoming # => an Enumerator of the ones dated from now on
oauth.visits.past # => an Enumerator of the ones dated before now

visit = visits.first
visit.id # => 'Z2lkOi8vS'
visit.name # => 'Furnace tune-up', or the visit's ID where nobody titled it. Never nil or empty
visit.title # => 'Furnace tune-up'
visit.job_id # => 'Z2lkOi8vS'
visit.starts_at # => 2026-08-09 14:00:00
visit.ends_at # => 2026-08-09 16:00:00
visit.all_day? # => false
visit.client_confirmed? # => true

Clients and properties

Jobber prices a query by what it brings back, so nothing nested comes back unless it is asked for. Chain includes the way Active Record does, on visits or on jobs:

visit = oauth.visits.includes(:client, property: :client).upcoming.first

visit.client.name # => 'Jane', or the business's name where the client is a business.
                  # Never an empty string: a blank first name falls through to the company
visit.client.first_name # => 'Jane'
visit.client.last_name # => 'Doe'
visit.client.company_name # => nil
visit.client.email # => 'jane@example.com'
visit.client.phone # => '5553335555', the reachable North American number, or nil

visit.property.id # => 'Z2lkOi8vS'
visit.property.street # => '1 Main St'
visit.property.city # => 'Raleigh'
visit.property.zip # => '27601'
visit.property.address # => { street: '1 Main St', city: 'Raleigh', state: 'NC',
                       #      zip: '27601', latitude: 35.77, longitude: -78.63 }
visit.property.client.name # => whoever the place sits on the file of

Ask for nothing and nothing arrives: oauth.visits.first.client.name is nil where the query never named a client.

Rate limits

Jobber holds an app to two limits at once: 2,500 requests every five minutes, and a bucket of query cost that drains as it is asked and refills at a rate it reports. Nothing has to be done about either — every request waits for itself:

  • It spaces itself 0.12 seconds from the request before, which is 2,500 spread evenly over five minutes.
  • It reads extensions.cost off each answer, and where the bucket can no longer pay for a page like the last one, it waits for the shortfall to refill at Jobber's own restore rate.

A request that follows no other waits for nothing, so a single find is as quick as it ever was. Only a walk long enough to be a problem is slowed, and only by as much as it must be.

Where Jobber refuses for cost anyway, the Jbr::Error raised says what the query would have cost against what was available — Throttled (cost 12400, 9500 of 10000 available, restoring 500/s) — so a query too big to ever run reads apart from a bucket that needed a moment. Every connection this gem asks for is bounded, because Jobber prices an unbounded one at its own maximum: twenty lines to a job, and twenty jobs or visits to a page.

Events

Parse the payload of a Jobber event webhook:

event = Jbr::Event.new data: { webHookEvent: { topic: 'JOB_CREATE', appId: 'app-1',
  accountId: 'account-1', itemId: 'job-1', occurredAt: '2026-05-22T15:46:33Z' } }
event. # => 'account-1'
event.item_id # => 'job-1'

Available mocks

Use these methods to mock request to Jobber when testing an app:

Credentials

Mock successfully creating and revoking credentials:

Jbr.mock

Mock an error when creating credentials:

Jbr.mock.oauth_error = 'Flow rejected'

Mock a custom redirect URL:

Jbr.mock.oauth_url = 'https://example.com'

Requests

Mock successfully creating a request:

Jbr.mock.request = { id: 'request-01', client_id: 'client-01' }

Quotes

Mock successfully fetching a quote:

Jbr.mock.quote = { id: 'quote-01', request_id: 'request-01' }

Jobs

Mock successfully fetching a job by ID:

Jbr.mock.job = { id: 'job-01', quote_id: 'quote-01', scheduled_at: Date.tomorrow.noon }

Mock the jobs the account has. The mock dates nothing it was handed: what answers to past and to upcoming is whatever scheduled_at the app gave each one:

Jbr.mock.jobs = [ { id: 'job-01', title: 'Furnace tune-up', status: 'archived',
  total: 260.0, quote_total: 240.0, created_at: Date.yesterday.noon,
  scheduled_at: Date.yesterday.noon, completed_at: Date.today.noon,
  property: { id: 'property-01', street: '1 Main St',
    client: { id: 'client-01', company_name: 'Acme Property Management' } } } ]

Mock the lines a job is made of, under the job that is made of them:

Jbr.mock.jobs = [ { id: 'job-01', line_items: [
  { quantity: 3.0, name: 'Bathroom Faucet Installation',
    description: 'Professional installation of a new bathroom faucet' },
  { quantity: 2.0, name: 'Change Toilet Valve' } ] } ]

oauth.jobs.past.first.summary
# => '3 Bathroom Faucet Installation and 2 Change Toilet Valve'

Visits

Mock the visits the account has:

Jbr.mock.visits = [ { id: 'visit-01', title: 'Furnace tune-up', job_id: 'job-01',
  property: { id: 'property-01', street: '1 Main St',
    client: { id: 'client-01', first_name: 'Jane' } },
  client: { id: 'client-01', first_name: 'Jane' },
  starts_at: Date.tomorrow.noon, ends_at: Date.tomorrow.end_of_day,
  all_day: false, client_confirmed: true } ]

Invoices

Mock successfully fetching an invoice:

Jbr.mock.invoice = { id: 'invoice-01', job_id: 'job-01', total: 19.99, issued_at: Date.yesterday.noon }