Home›Jobora›Manual
Jobora · Manual

Jobora Manual

Jobora — User Manual

Field-work management: customers, jobs, a schedule of visits, quotes, invoices — and the margin on every job. Version 4.1.3 [src: app/controllers/api.php:18].

About this manual

Every statement here was written by reading Jobora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug.

Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.

⭐ The margin view is the whole argument

The source states the case for the product in one sentence: a notes app will tell you a job happened; it will not tell you that the job you quoted at £1,400 cost you £1,610 in parts and hours [src: app/src/Jobs.php:5].

Run that exact example through the product's own arithmetic — three parts at £270 and eight hours at £100:

materials    810.00
labor       800.00      (480 minutes)
cost       1,610.00
quoted     1,400.00
margin      −210.00      −15.0%      tone: bad

The definitions are stated where the arithmetic lives [src: app/src/Jobs.php:10]:

  • quoted is the snapshot taken when the quote was accepted, never the live quote — because a quote edited after acceptance must not silently rewrite last month's margin. The snapshot is written onto the job at conversion [src: app/controllers/app.php:1067] and everything downstream reads that column [src: app/controllers/app.php:179].
  • cost is materials (quantity × unit) plus labor (minutes ÷ 60 × rate) [src: app/src/Jobs.php:63].
  • margin is quoted − cost, and margin % is margin ÷ quoted.

⭐ Unknown is not zero

A job with nothing quoted has no margin to report, and the source is explicit that printing "−£1,610" against it would read as a loss on work that was never priced here [src: app/src/Jobs.php:93]. So both figures come back null. Executed on the same cost rows with nothing quoted:

cost_cents 161000   quoted false   margin_cents NULL   margin_bp NULL   label "—"

That is the difference between a number you can act on and a number that would make you cancel a profitable line of work.

Money and time are integers

Every figure is integer cents, and hours are minutes so a 20-minute call is not 0.3 of anything [src: app/src/Jobs.php:16]. Executed:

20 minutes @ £100.00/h  =  3333 cents
 7 minutes @  £95.00/h  =  1108 cents

On the quote and invoice form, a row with no description and no price is not a line, whatever its quantity says, so the four spare rows under your lines never reach the customer [src: app/controllers/app.php:879]. Before 4.1.2 each saved quote or invoice gained four empty $0.00 lines. One saved earlier loses them the next time you edit and save it; an accepted quote cannot be edited, so it keeps them [src: app/controllers/app.php:924].

One note on reading the margin chip: its color thresholds are display only, never a rule [src: app/src/Jobs.php:120], and the label rounds to one decimal place — so 14.99% and 15.00% both print as "15.0%" while showing different colors. Trust the color boundary, not the rounded label.

The life cycle, and refusals in the operator's words

Five statuses [src: app/src/Jobs.php:23], and a job may only move where the transition table allows [src: app/src/Jobs.php:26]. A completed job is not re-opened — a follow-up is a new job. Executed, with the refusal each attempt actually produces [src: app/src/Jobs.php:40]:

completed   -> in_progress   A completed job stays completed — raise a follow-up job rather
                             than reopening the record.
canceled   -> scheduled     A canceled job stays canceled. Raise a new one.
scheduled   -> completed     A job cannot go from Scheduled to Completed.
completed   -> completed     That job is already Completed.
scheduled   -> nonsense      That is not a job status.
in_progress -> completed     ALLOWED

Refusals are written for the person reading them, not for a log.

Quotes convert once

Acceptance is what converts a quote into a job, and it converts once [src: app/src/Jobs.php:159]. The refusals are the interesting part [src: app/src/Jobs.php:163]: a quote that already became a job cannot spawn a second one, a declined quote is not an agreement to do work, and a draft has to be sent before it can be accepted.

Customer email: two gates, both off

ClientMail is the only thing in Jobora that emails a customer [src: app/src/ClientMail.php:3], and the guarantees are worth knowing before you turn it on:

Two switches, each defaulting to off in the schema — a master flag and a per-event flag. Installing or upgrading never starts emailing anybody's customers; you have to say yes twice [src: app/src/ClientMail.php:8].

Your SMTP, not ours. There is no Jobora relay, no shared sender, no fallback account [src: app/src/ClientMail.php:11].

A demo install opens no socket — it writes an outbox row instead, because a public demo that could send real mail to a real address is a liability rather than a feature [src: app/src/ClientMail.php:12].

A mail failure never breaks the work. Accepting a quote, booking a job and issuing an invoice all succeed whatever the mail server is doing; the failure is recorded and reported, not raised [src: app/src/ClientMail.php:14].

And every message carries only what the customer already has a right to see — their own document, job and dates. No internal notes, no margin, no other customer, no staff comment [src: app/src/ClientMail.php:18].

⭐ Get paid from the invoice (1.1)

Off until you switch it on in Settings → Get paid by card [src: app/controllers/v11.php:63]. With your own Stripe secret key (a restricted key that can create Checkout Sessions is enough), the webhook signing secret and the currency your invoices are in, an unpaid invoice's page shows the customer a Pay by card button for what is still owed [src: app/src/Pay.php:80].

  • The card never reaches Jobora. Pressing the button asks Stripe, with your key, for a checkout page for that invoice's balance at that moment, and sends the customer there [src: app/src/Pay.php:104]. The money goes from Stripe to your bank. Jobora takes no cut.
  • The page is made when the customer presses the button, not in advance: a Stripe checkout page expires within a day, and an invoice is often opened a week after it was emailed. The email therefore links to the customer's own invoice page, which never expires.
  • Stripe tells Jobora it went through. Add the endpoint shown on the settings page (/hooks/stripe) in Stripe with the events checkout.session.completed and checkout.session.async_payment_succeeded. An event is read only if its signature verifies against your signing secret and it is under five minutes old [src: app/src/Pay.php:164]; a checkout in another currency, an unpaid one, or one whose invoice number does not match records nothing [src: app/src/Pay.php:185].
  • Once, and in part. Each Stripe session is recorded at most once, so Stripe's retries cannot pay an invoice twice [src: app/src/Pay.php:213]. A payment adds to what is already recorded: the invoice reads part-paid until nothing is owed, then paid, and the invoice.paid webhook fires.
  • Recording a payment by hand adds too. The amount field offers the balance; an empty amount means "the rest of it". Before 1.1 the field replaced whatever had been recorded.

A deposit when a quote is accepted. Set a percent in Settings → Get paid by card. Accepting a quote then raises a deposit invoice for that share of the quote's total. That happens at every door: the office, the customer's own page, the API or an agent. The invoice is sent, due that day, carries no tax of its own, and is payable by card from its link when card payment is on [src: app/src/Deposits.php:28]. When the job's invoice is raised from the quote, it carries a "Less deposit invoiced on INV-…" line for the same amount, so the two invoices together come to exactly the quote [src: app/src/Deposits.php:47]. One deposit per quote, ever. The customer's quote page says so before they accept and links to the deposit invoice afterward.

⭐ Automatic follow-ups (1.1)

The chasing you would otherwise do by hand, in three kinds, each off until you tick it in Settings → Automatic follow-ups [src: app/controllers/v11.php:100]:

kindwhenstops when
payment remindersdays from the due date, default -3, 0, 7, 14 [src: app/src/Chaser.php:42]the invoice is paid or paused
quote follow-upsdays after the quote was sent, default 3 and 10 [src: app/src/Chaser.php:43]the quote is accepted, declined, past its valid-until date, or paused [src: app/src/Chaser.php:363]
visit remindersthe day before a planned visitthe visit is canceled or the job completed [src: app/src/Chaser.php:374]

The rules err on the side of not sending [src: app/src/Chaser.php:265]:

  • Armed only when three things agree: the kind's switch, customer email (the master switch) and your own SMTP server [src: app/src/Chaser.php:219].
  • At most once per step. The outbox row is claimed before anything is sent, so two page loads running the schedule together cannot both mail a customer [src: app/src/ClientMail.php:199].
  • Never a burst. A step keeps trying for up to seven days after its own day, and the next step takes over as soon as its day comes, so an invoice first seen long overdue gets one reminder, not four [src: app/src/Chaser.php:49].
  • A quiet day. Nothing automatic goes on the day, or the day after, anything else about that document reached the customer [src: app/src/Chaser.php:330].
  • Pause per quote or invoice, per job (visit reminders) and per customer, from each page.
  • A daily cap (default 20), and a refused send ends the run; that step is retried the next day.
  • Your wording. Each step's subject and body are editable; the defaults are Invora 3.2's released reminder wording, and a due-date step that goes out late says "was due on" rather than "is due today". Every invoice and quote message ends with the customer's own link.

It runs by itself: a few go each time someone opens the dashboard [src: app/controllers/app.php:221], and a server with cron can call /cron/chaser?t=<token> once a day with the scheduled-backup token [src: app/controllers/v11.php:151]. Settings → What goes out today shows exactly what would go, and opening it sends nothing [src: app/controllers/v11.php:144].

⭐ Text messages on your own SMS account

Jobora can text your customers through your own SMS account: Twilio, or any SMS provider that takes an HTTPS request. It is off until you tick Send text messages in Settings → Text messages and fill in a gateway address [src: app/src/Sms.php:53].

Who pays. Your SMS provider bills you for every text, at its own rates. Jobora sells no credits, adds no margin and has no sending service of its own [src: app/src/Sms.php:3].

Setting it up, in Settings → Text messages:

  • Gateway address: the HTTPS address your provider gives you for sending one message. Only https:// addresses are accepted.
  • Key and Sent as: a Bearer token, HTTP Basic (user:password; for Twilio, AccountSid:AuthToken), or no key [src: app/src/Sms.php:71]. Once saved, the key is never shown again: leave the box empty to keep it, or tick the box to forget it [src: app/controllers/sms.php:34].
  • Request body and Field names: JSON or form fields, and the three names your provider expects for the number, the sender and the message. The default is to, from, text; Twilio takes form fields named To, From, Body.
  • From: your sender number or name, if your provider needs one.
  • Send a test text sends one message to a number you type, so you see your provider accept it [src: app/controllers/sms.php:43]. An answer in the 200 range counts as sent; anything else shows the provider's answer.

Only customers who agreed get texts. Each customer has an Agreed to texts box, unticked unless someone ticks it. Tick it only when the customer said yes [src: app/controllers/app.php:365]. When online booking and texts are both on, the booking page shows an unticked box that reads "Send me texts about my jobs: visit reminders, when the technician is on the way, and my quotes and invoices." Accepting a request from someone who ticked it marks the customer as agreed. For a customer already on file, that happens only when their record has the number they booked with, or no number yet (it then takes that one); a record with a different number is left as it was [src: app/src/Booking.php:129]. A phone number on file is never enough: the agreement is checked in the query that picks who is due, and again just before each send [src: app/src/Sms.php:359] [src: app/src/Sms.php:137]. The number has to look like a phone number, 7 to 15 digits, where a leading 00 becomes + [src: app/src/Sms.php:60]. Jobora does not add a country code, so store numbers the way your provider wants them, usually + and the country code.

The four texts:

textwhenswitched on byhow often
visit remindera set number of hours before a planned visit starts, for a job that is scheduled or in progress (24 by default, up to 168; 0 sends none)texts ononce per visit and start time; moving the visit to a new day or time sends one more, for the new time [src: app/src/Sms.php:368]
on the waya technician taps Text: on my way under a planned visit on the job pagetexts ononce per visit per day [src: app/src/Sms.php:302]
quote readyyou send a quote; the text carries its linkits own tick, off until you tick itonce per quote, even if you send the quote again [src: app/src/Sms.php:321]
invoice duethe due date of a sent invoice that still has money owed; the text carries its linkits own tick, off until you tick itonce per invoice per due date [src: app/src/Sms.php:386]

The Text: on my way button shows only for a customer who agreed and has a number that can be texted; once the text has gone it reads "Texted: on the way". Anyone who can edit jobs can tap it; a read-only user cannot.

Every text starts with your business name (the display name under Branding, or else the company name) and ends with "To stop texts:" and the customer's own link [src: app/src/Sms.php:146]. The wording is fixed. A visit reminder reads:

Acme Plumbing: reminder of your visit on Wed 5 Mar at 09:00 (job JOB-1042). To stop texts: https://jobs.example.com/sms/stop/…

How a customer stops texts. The link at the end of every text opens a page with one button, Stop all texts. Pressing it takes the customer's agreement away at once and writes it to the audit log [src: app/controllers/sms.php:83]. It takes the press, so a phone's link preview cannot stop texts on its own. You can also untick Agreed to texts on the customer; tick it again only if the customer asks for texts again. Jobora does not read replies: a customer who replies STOP reaches your provider, not Jobora. If your provider then refuses to text that number, the refusal shows as a failed text in Settings.

When the automatic texts go. Visit reminders and invoice-due texts go only inside your send window: 09:00 to 20:00 in your business time zone, unless you change the hours. The start has to be before the end; a window that is not falls back to 09:00 to 20:00, so a typo cannot send texts at night [src: app/src/Sms.php:154]. At most 20 automatic texts go a day unless you set another number, from 1 to 200, under At most this many automatic texts a day; past it, the rest wait for the next day. "On the way" and "quote ready" do not count toward it [src: app/src/Sms.php:178]. A reminder that comes due at night waits for the window to open; if the visit starts before then, no reminder goes. "On the way" and "quote ready" go when someone acts, at any hour. The automatic texts go the same way as the automatic follow-ups: at most five each time someone opens the dashboard [src: app/controllers/app.php:224], and all that are due each time your cron calls /cron/chaser?t=<token> [src: app/controllers/v11.php:157]. With no cron and nobody opening the dashboard, nothing goes.

Exactly once. Before a text is sent, its send key is written to the texts log, and the log refuses a second copy of a key. Two page loads, a double tap or two cron runs at the same moment cannot text anyone twice [src: app/src/Sms.php:245].

A failed text is shown, and tried again at most once a day. When your provider refuses a text or cannot be reached, the text is logged as failed with the provider's answer, nothing else breaks (the page loads, the job or quote is saved), and the dashboard names the job or invoice [src: app/src/Sms.php:96]. The automatic run stops at that text, so a broken gateway is not handed every text that is due [src: app/src/Sms.php:378]. A failed text is not tried again the same day. On each later day, while it is still due, it is tried once that day [src: app/src/Sms.php:256]: a visit reminder when the visit is still ahead, a quote text when you send the quote again; an invoice-due text is not, because its day has passed. A failed "on the way" shows as failed under the visit for the rest of that day. Texts sent in Settings lists the last 15 texts with their status, showing only the last four digits of each number [src: app/src/Sms.php:414]. Each attempt is also in the audit log, with the number cut to its last four digits [src: app/src/Sms.php:287].

A demo install sends nothing. Texts are logged as "demo" and no connection is made; the test text is refused [src: app/src/Sms.php:278].

Privacy. Erasing a customer's personal data also clears the number and the wording of every text sent to them [src: app/controllers/v3.php:767]. The shareable JSON backup leaves out your gateway key and every customer's stop link.

Texts and emails are separate. If visit reminder emails are on under Automatic follow-ups too, a customer who agreed to texts and has an email address gets both.

⭐ Repeat work (1.1)

Repeat work holds the plans a cleaning, gardening or maintenance-contract business runs on: this customer, this work, this technician and time, every week, every few weeks or every month on a day (the 31st in a shorter month is its last day), from a first date to an optional last one [src: app/src/Recurring.php:27].

  • Eight weeks ahead, by itself. Each date becomes an ordinary job with its visit on the board, once a day from a page load, straight away when you save a plan, or from /cron/recurring?t=<token> [src: app/src/Recurring.php:86].
  • One job per date, ever. The date is claimed before the job is made, so two page loads cannot both make it, and a date whose job you deleted is remembered as skipped and never comes back [src: app/src/Recurring.php:139].
  • Never double-booked. Each visit goes through the same conflict function as the drag, the form, the API and the agent; a date the technician is already booked is created as a job with no visit and flagged, and waits under "Open job with nothing on the schedule" [src: app/src/Recurring.php:112].
  • One-offs stay one-offs. Moving, changing or canceling one visit never touches the plan; changing the plan affects only dates not yet placed.
  • A price per visit becomes each job's agreed figure (so its margin shows) and its invoice is raised at that price, one invoice per visit.
  • Stopping a plan places nothing more; tick "remove upcoming visits" and the future ones nobody has started (no cost, photo, sign-off or invoice) go too [src: app/src/Recurring.php:150].

Job checklists (1.1)

Checklists (Settings) holds the checks a kind of job always needs, one per line. Add one to a job, or to a repeat-work plan so every visit arrives with it, and the technician answers each check on the job page: done or not applicable, with a note [src: app/src/Checklists.php:69]. Checks can also be added to one job by hand.

A job with an open check cannot be completed, whichever door tries: the job page, the REST API or the agent tool all ask the same question after the life-cycle check [src: app/src/Checklists.php:81]. "Not applicable" is an answer; blank is not. Once the job is completed its checklist is part of the record and does not change, and the job sheet PDF prints it.

Online booking (1.1)

Off until you switch it on in Settings → Online booking; then link to /book from your website. The page offers morning and afternoon windows over the next two weeks, weekdays unless you tick weekends, and only the windows where at least one bookable technician has nothing on the board [src: app/src/Booking.php:50] (the technicians you list, or everyone on the team).

  • A request, not a booking. The customer picks a window and describes the work; the window is checked again when they send it [src: app/src/Booking.php:73]. It waits in Booking requests until someone accepts it or declines it with a reason the customer reads on their own link.
  • Accepting finds the customer by email or creates them, raises the job and places the visit through the same conflict function as the board, so a time that has filled up meanwhile is refused, never double-booked [src: app/src/Booking.php:113]. The booking-confirmation email goes if you have it switched on.
  • Guards: a hidden trap field, and at most five requests an hour from one address (a salted fingerprint, never the address itself).

The day's route (1.1)

Schedule → Today's route (or /route?date=YYYY-MM-DD) lists each technician's stops for the day in booked order, each with its site address (or the customer's), the areas it covers (the postcode district, ZIP or town), and a Directions link that opens Google Maps through every stop in that order [src: app/src/Route.php:39]. Open jobs with nothing booked are grouped by area underneath, so you can see which ones sit near a round you already have.

  • Ordered by time, not optimized. Jobora puts the stops in the order they are booked; it does not reorder them for distance, and it calls no map service itself. The directions link is an ordinary Google Maps address built from your addresses [src: app/src/Route.php:29].

QuickBooks Online import files (1.1)

Quotes & invoices → For QuickBooks downloads your issued invoices (drafts stay out; add ?from=YYYY-MM-DD&to=YYYY-MM-DD to the address for a date range) as one row per line, with each line's tax, and a negative "Discount" row where the invoice has one; the rows of an invoice add up to the total Jobora shows [src: app/src/QuickbooksExport.php:22]. /export/quickbooks-customers.csv gives your customers the same way. In QuickBooks Online, use Import data and match the columns on its mapping screen.

  • An export, not a sync. Jobora holds no connection to Intuit and sends nothing. Import again when you want newer invoices across; QuickBooks, not Jobora, decides what to do with an invoice number it already has.

Field staff see no prices (1.2)

Settings → Field staff and prices. Switched on, a member (the technician role) works the job without seeing money [src: app/controllers/v3.php:48]:

  • Quotes, invoices, payment reminders, the exports, and raising an invoice answer "not allowed" (403) [src: app/index.php:270].
  • The jobs list, the job page, the customer page, repeat work, the dashboard and the job sheet PDF leave out every price, rate, cost total and margin.
  • Hours a member logs are costed at your labor rate, and a member editing repeat work keeps its price.
  • A member's API or AI-agent key gets jobs, visits and customers without the money fields, and is refused the quote, invoice and reminder doors and the margin report.

Administrators and viewers see everything as before; a viewer is often the bookkeeper. It is off until you switch it on.

Users and roles

Three roles [src: app/controllers/v3.php:23], with the reasoning stated: a field technician needs to run the day and record what happened; they do not get to delete a job, move money, or change what the company charges [src: app/controllers/v3.php:19].

Executing the product's own permission function rather than reading the map:

job.view        viewer=Y  member=Y  admin=Y
job.edit        viewer=n  member=Y  admin=Y
photo.upload    viewer=n  member=Y  admin=Y
settings.edit   viewer=n  member=n  admin=Y
audit.view      viewer=n  member=Y  admin=Y

The viewer's grants are listed, not wildcarded

A viewer holds job.view and reports.view and nothing else [src: app/controllers/v3.php:23], so the audit page and its CSV export, which require audit.view [src: app/controllers/v3.php:123], refuse a read-only account. Before 1.0.4 the viewer held the wildcard *.view, which matched audit.view too.

Backups are gated properly: both downloads require settings.edit [src: app/controllers/api.php:344], and the restore page and the scheduled-backup token require settings.manage; no role below admin holds either.

The JSON backup is the one you can share, so it blanks every credential column — password hashes, 2FA seeds and recovery codes, API-key and invite token hashes, webhook secrets, the SMTP password, the SSO client secret, the Stripe keys (1.1), the backup and calendar tokens, and, from 1.1, every webhook URL (some carry the credential in the address; re-enter them after restoring onto a fresh install) — and, from 1.0.4, each quote's customer link, because that link opens the quote and can accept it [src: app/controllers/v3.php:111]. A restore keeps the link of every quote that still exists; a quote the restore brings back after it was deleted gets a new link, so send it to the customer again. On a SQLite install the .sqlite download is the whole database, secrets included, for bare-metal recovery.

An API key carries its holder's role at the REST door as well as the agent door: creating or moving a job, booking a visit and accepting a quote over /api/* need job.edit [src: app/controllers/api.php:70], so a viewer's key is refused with 403. Before 1.0.4 only the key's read/write scope was checked there.

Webhooks: the events on offer are the events Jobora sends

The settings page offers exactly what the code fires [src: app/src/Webhook.php:22]:

eventbrowserRESTagent
job.created[src: app/controllers/app.php:490][src: app/controllers/api.php:99][src: app/controllers/mcp.php:228]
job.<status> — job.scheduled, job.in_progress, job.on_hold, job.completed, job.cancelled[src: app/controllers/app.php:522][src: app/controllers/api.php:120][src: app/controllers/mcp.php:294]
quote.acceptedthe one conversion function every door calls [src: app/controllers/app.php:1086]samesame
job.signed_off[src: app/controllers/app.php:1294]——
invoice.paid (1.1)a card payment through your own Stripe was recorded [src: app/src/Pay.php:237]——

Sign-off exists only in the browser, where the customer signs. Before 1.0.4 the page offered workorder.created, workorder.completed and reading.recorded, another product's events, so none of the real ones could be ticked; an endpoint saved with no events ticked still receives everything [src: app/src/Webhook.php:33].

One version, everywhere

The ping, the OpenAPI document, the agent probe and specification, the health check and the version stamped into every backup all read the same constant [src: app/controllers/api.php:18], [src: app/controllers/api.php:339], [src: app/controllers/mcp.php:41]. Before 1.0.4 three of them said 1.0.0 and two said 3.1.1.

The API and agent access

Read-only over REST: GET /api/chaser shows where every open invoice, quote and tomorrow's visits stand in the follow-up schedule, with no addresses and no message bodies [src: app/controllers/v11.php:180].

Eight tools [src: app/controllers/mcp.php:88]: four read — the job list with margins, one job in detail, a day's schedule and a margin report — and four write: creating a job [src: app/controllers/mcp.php:199], booking a visit [src: app/controllers/mcp.php:233], advancing a job [src: app/controllers/mcp.php:272] and accepting a quote [src: app/controllers/mcp.php:299].

The instructions given to an agent restate the rules that matter, including the one this manual leads with [src: app/controllers/mcp.php:82]:

"Money is INTEGER CENTS everywhere and is computed by the product, never by the caller … margin_cents is null when nothing was quoted — that means unknown, not zero."

They also warn that schedule times are local wall-clock strings rather than timestamps, and that a visit overlapping one the same technician already has is refused.

What Jobora does not do

It does not reopen a completed job [src: app/src/Jobs.php:25] — a follow-up is a new job.

It does not let an accepted quote's later edits rewrite a past margin [src: app/src/Jobs.php:10].

It does not report a margin it cannot know [src: app/src/Jobs.php:93].

It does not hold card details. Card payment happens on your own Stripe account's page; Jobora records what Stripe reports [src: app/src/Pay.php:104].

It does not email your customers until you switch it on twice [src: app/src/ClientMail.php:8], and it never sends through anything but your own SMTP [src: app/src/ClientMail.php:11].

It does not put internal notes or margin figures in a customer's email [src: app/src/ClientMail.php:18].

It does not text a customer who has not agreed to texts [src: app/src/Sms.php:137], and it sends texts only through your own SMS account; it sells no text credits [src: app/src/Sms.php:3].

Data requests: what an erase removes and what it keeps (4.1.1)

Data requests (whoever manages settings) exports everything held about a customer, or erases their details once none of their jobs is open. Erasing replaces their name, contact name, email, phone, address and notes on the customer record with [removed], and so are the site address on their jobs and recurring plans, the public booking request a job came from, and the texts and emails sent to them. The jobs themselves stay: what was quoted, done, charged and invoiced is the business's own trading record.

Kept: the customer's sign-off on a job (the name, role, statement and signature, and the address it was signed from), the name typed to accept a quote, and the audit trail's lines that the customer was added or edited, because the sign-off and the acceptance are the evidence a dispute is settled with and the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Jobora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Jobora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Jobora is unavailable for about a minute while this runs. The update replaces Jobora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Jobora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Jobora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a small maintenance firm's jobs, quotes and customers to try things on. You can remove it in one click. [src: app/install/index.php:126]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample jobs, quotes and customers? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:62]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Jobora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Jobora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/jobora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Switching from Jobber: your clients

On the Import page (/import), Switch from another tool takes the file Jobber exports. In Jobber: Clients > More Actions > Export Clients > CSV > Export (Jobber emails you the file). Upload it and press Read the export (nothing is written yet): the switch report shows how many rows will import, which are already here from an earlier import, which repeat a row above, which have a problem (by line), and which columns do not come across. Then import them in one click. Importing the same file again adds nothing twice [src: app/views/_switch.php].

Connect it to other tools (Zapier, Make, n8n)

Jobora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Jobora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. Single sign-on starts a fresh session. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Jobora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

Single sign-on now starts a fresh session too [src: app/controllers/v3.php:560].

On Ownware Cloud, opening Jobora from the console starts a fresh session [src: app/index.php:69].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:20].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Jobora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Jobora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Jobora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Jobora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Jobora · Quickstart · API · Test run