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:

Where several processes hold copies of the same credentials — a queue of workers, each building its own — hand over a store: as well, and only one of them will ever spend the refresh token:

oauth = Jbr.oauth_for account_id:, invalid_at:, store: credentials

The store is anything answering two methods. exclusively takes whatever lock the app keeps over those credentials and yields them as they are right now, read inside that lock; write records the ones Jobber handed back:

def exclusively = with_lock { yield oauth_params }   # Active Record, in an app that has it

def write(oauth) = update oauth: oauth

An expired access token is then refreshed once. Every other holder takes the lock, finds a token that is no longer the one it tried, adopts it, and asks Jobber nothing — where without a store each of them would spend a refresh token the first has already spent, and Jobber would call every one of those a dead grant.

exclusively has to be exclusive against every process sharing the credentials, not just every thread: a Mutex satisfies this interface and fixes nothing on a fleet of workers.

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.name # => 'Bathroom Faucet Installation'
line.to_s # => '3 Bathroom Faucet Installation', how many of what, and the name alone where
          #    Jobber holds no quantity for the line

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. This gem does nothing about either — it never sleeps, and it never asks a second time. What it does is say exactly what happened, so the caller can decide:

Throttled (cost 1885, 1254 of 10000 available, restoring 500/s)

That arrives as a Jbr::Error. 631 points short of a query the bucket holds five times over, which a second would have refilled — worth asking again. A cost above maximumAvailable is worth nothing but a smaller query. Either way the decision belongs to whoever called: from a background job, letting it fail so the queue brings it back is better than a worker asleep holding a transaction open.

Every connection the gem asks for is bounded, to keep a query on the affordable side of that: 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 }