Documentation Reference
API and webhooks
The REST API under /api/v1 — authentication, the envelope, every endpoint — and the signed webhooks the app sends when something changes, with a verifier you can copy.
Everything you can do with companies, deals, time and invoices in the app, a script can do over a JSON API. And when something happens — a lead found, a site published, an invoice paid — the app can tell another system about it.
Authentication
The base URL is the app's own address, followed by /api/v1. Send the key in
either header:
curl -s "https://app.example.com/api/v1/invoices?status=Sent" \
-H "Authorization: Bearer $OPPORTUNORA_API_KEY"
curl -s "https://app.example.com/api/v1/invoices" \
-H "x-api-key: $OPPORTUNORA_API_KEY"
A key looks like lse_ followed by 64 hexadecimal characters. It belongs to one
workspace and carries one of the six roles, which decides what it may change —
see People and roles. Every key can read everything
in its workspace.
| Status | Error | Cause |
|---|---|---|
| 401 | Missing API key. Provide an Authorization: Bearer <key> header. |
No key sent. |
| 401 | Invalid or revoked API key. |
Unknown or deactivated key. |
| 403 | Permission denied: missing invoices.write |
The key's role cannot do that. |
The envelope
Success:
{ "success": true, "data": { "id": "…", "name": "Bluebird Bakery" } }
Failure:
{ "success": false, "error": "Validation failed", "details": [ … ] }
| Status | When |
|---|---|
| 200 | Read, update or action succeeded. |
| 201 | Created. |
| 400 | The body is not a JSON object, or it failed validation — details lists the problems. |
| 401, 403 | Authentication or permission, as above. |
| 404 | No such record in this workspace. |
| 409 | A business rule refused it: a locked time entry, a paid invoice, a duplicate. |
| 500 | Anything else. Some rule refusals arrive as 500 today; read error. |
Lists return every matching record in one array. There is no pagination and no sort parameter.
Endpoints
| Resource | Endpoints | List filters |
|---|---|---|
| Companies | GET POST /companies · GET PATCH DELETE /companies/<id> |
stage, search, runId, isActive |
| Opportunities | GET POST /opportunities · GET PATCH DELETE /opportunities/<id> |
|
| Projects | GET POST /projects · GET PATCH DELETE /projects/<id> |
status |
| Time entries | GET POST /time-entries · GET PATCH DELETE /time-entries/<id> |
|
| Invoices | GET POST /invoices · GET PATCH DELETE /invoices/<id> |
status |
| Invoice actions | POST /invoices/<id>/send · /mark-paid · /cancel · /reopen |
|
| Websites | GET POST /deliverables · GET DELETE /deliverables/<id> |
status |
| Approvals | GET POST /approval-items · GET /approval-items/<id> |
Things to know about them:
PUTis the same asPATCH: a partial update. The id in the path wins over any id in the body.- Invoices are always created as drafts. A
statusin the body is ignored; statuses move through the action endpoints. A number in the body only contributes its prefix — the sequence is always assigned by the app. - Delete is for drafts. An invoice with any payment on it cannot be deleted; cancel it.
- Websites cannot be edited over the API. Their content changes through generation and the editor.
- Two endpoints exist but do not work in this build:
POST /deliverables/<id>/publishandPOST /approval-items/<id>/decide. Use Approve & publish in the app.
There is no endpoint for discovery runs, generation, packages, settings, users or payment connections.
A company, for example:
curl -s -X POST "$APP_URL/api/v1/companies" \
-H "Authorization: Bearer $OPPORTUNORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Bakery Meier","city":"Bern","countryCode":"CH"}'
Webhooks
A webhook subscription is a URL, a secret, and the list of events to send to it.
For each matching event the app makes one POST:
POST /your/endpoint
Content-Type: application/json
User-Agent: Opportunora-Webhook/1.0
X-Webhook-Event: invoice.paid
X-Webhook-Id: <the subscription's id>
X-Webhook-Signature: sha256=<hex>
{
"event": "invoice.paid",
"timestamp": "2026-09-10T12:34:56.789Z",
"data": { "id": "…", "invoiceNumber": "INV-1042", "amountPaid": 2400, "currency": "CHF" }
}
The events
| Event | Sent when |
|---|---|
company.discovered |
A company is added by hand, or saved by a discovery run. |
opportunity.stage_changed |
A deal moves stage. |
deliverable.generated |
A website version finishes generating. |
deliverable.published |
A website goes live. |
approval.decided |
An approval is decided. |
invoice.sent |
An invoice is sent. Carries its payUrl. |
invoice.paid |
Money is recorded. A payment from Stripe or PayPal adds a payment object with the provider, method, gross, fee and net. |
invoice.canceled |
An invoice is canceled. |
invoice.reopened |
A refunded or partly refunded invoice is put back up for payment. |
invoice.refunded |
A provider refund takes an invoice to Refunded. |
A subscription names events exactly. There are no wildcards.
Verifying the signature
The signature is an HMAC-SHA256 of the raw request body with the subscription's secret. Verify it before you parse the body:
// Node 18+
const crypto = require('node:crypto')
function verify(rawBody, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(header ?? '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express: take the raw body, not the parsed one.
app.post('/hooks/opportunora', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('X-Webhook-Signature'), process.env.OPPORTUNORA_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
const { event, data } = JSON.parse(raw)
// …
res.sendStatus(200)
})
The signature covers the body only; there is no timestamp in it and no replay
window. X-Webhook-Id identifies the subscription, not the delivery, so use
the record's own id in data if you need to de-duplicate.
Queue metrics
For autoscaling workers on a self-hosted deployment, GET /api/queues/depth
reports queued, running and runnable work across the whole deployment. It is off unless
QUEUE_METRICS_TOKEN is set, and it takes that token, not an API key:
curl -H "Authorization: Bearer $QUEUE_METRICS_TOKEN" "$APP_URL/api/queues/depth"
{
"discovery": { "queued": 0, "running": 0, "runnable": 0, "oldestWaitSeconds": 0 },
"generation": { "queued": 0, "running": 0, "runnable": 0, "oldestWaitSeconds": 0 },
"publish": { "queued": 0, "running": 0, "runnable": 0, "oldestWaitSeconds": 0 },
"totalQueued": 0,
"totalRunning": 0,
"totalRunnable": 0,
"scalingDemand": 0
}
Scale on running plus runnable jobs per queue, divided by the capacity of each worker. scalingDemand is the total running plus runnable work. Raw queue depth includes jobs the workspace plan cannot start yet. See Run your own
deployment.