TOVRReference

Factoring partners

How a factoring company integrates with TOVR — one OAuth2 client per consenting organization, invoice.recorded webhooks signed with HMAC-SHA256, a refetch through GET /v1/invoices, catch-up from the change feed, and the rate limits that apply. Also covers what TOVR does not do, and what TOVR would need from you for a deeper integration.

This page is for engineers at a factoring company who want to know when a TOVR customer has invoiced a transport, so they can offer to finance that invoice. It explains how the pieces fit together. The reference pages it links to hold the detail.

In the examples, the factor is Smart Factoring (Xpress Factoring). They are an example, not a live partner: no agreement with them exists today.

What TOVR is, and what you get

TOVR is a freight platform for road carriers and forwarders. Dispatchers plan transports in TOVR, and when a transport is finished they record the invoice their ERP issued for it. That record is the moment a factor cares about. The carrier now holds a receivable, often on 30 to 90 day terms, and may want the cash sooner.

If you integrate, you get a signed webhook within seconds of that moment. It carries the invoice's id. With the id you read the invoice record: its number, amount, currency, issue date and the transports it covers. This works for every TOVR organization that has chosen to connect with you, and for no other organization.

Tenancy: one client per consenting organization

TOVR has no cross-organization API. Each TOVR customer is an organization, and each API credential belongs to exactly one organization. So a factoring partner holds one OAuth2 client per organization that has agreed to work with it:

  1. An admin of the customer's organization creates a client application in Settings → Developer. They grant it the scopes below and send you the client_id and client_secret through a secure channel.
  2. You store that pair against the customer's identity in your own system.
  3. Every token you mint with that pair sees that organization's data and nothing else. The organization comes from the token. You never send an organization id.

"Can you send us all your carriers?" No. There is no endpoint that lists TOVR's customers, their carriers or their invoices across organizations. None is planned. Each organization decides for itself whether you see its invoices, and it can withdraw that at any time by deleting the client application. TOVR does not send you data about an organization that has not connected to you.

This also sets your rate limits. Limits are per client application, and the numbers come from that organization's plan (see Rate limits and quotas).

Credentials

Ask the organization's admin to grant exactly these scopes. A client application must have at least one scope. A scope that is not granted is refused with 403.

ScopeWhat it gives youNeeded for
invoices:readGET /v1/invoices and GET /v1/invoices/{id}Reading the record an event points at. Subscribing to invoice.recorded.
events:readGET /v1/eventsCatching up after downtime.
webhooks:writeCreate, change, rotate and delete webhook endpointsSubscribing.
webhooks:readList endpoints and read their delivery statusChecking whether your endpoint was disabled.

One more scope is useful if an organization chooses to grant it: shipments:read lets you read the shipment_ids in an invoice's covers[] (the route, the stops, the cargo). You do not need it to finance an invoice. Ask only for what you use.

Mint a token with the client-credentials grant at POST /api/v1/oauth2/token:

curl -X POST https://tovr.eu/api/v1/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$TOVR_CLIENT_ID" \
  -d "client_secret=$TOVR_CLIENT_SECRET"
{ "access_token": "<jwt>", "token_type": "Bearer", "expires_in": 3600 }

The token lasts 3,600 seconds (one hour). There is no refresh grant, so mint a new token before the old one expires. The token keeps the scopes the client had when it was minted. If an admin later removes a scope, your current token keeps working with the old scopes until it expires. Full detail is in Authentication.

Staging is https://staging.tovr.eu. Production is https://tovr.eu. The paths are the same.

The event model

TOVR records changes in a ledger. Each entry is a pointer, never a row:

{
  "object": "event",
  "id": "aev_5f0c1d2e-3a4b-4c5d-8e6f-708192a3b4c5",
  "type": "invoice.recorded",
  "object_type": "invoice",
  "object_id": "inv_0b6f4a8e-5c1d-4e2f-9a7b-3c8d2e1f0a9b"
}

When you receive an event, refetch the object: GET /v1/invoices/{object_id}. The event does not contain an amount, a carrier name or anything else from the record.

This is on purpose. It means an event can never leak a field you are not scoped for. The refetch is where TOVR checks your scopes and your organization. The event has nothing in it to check. If you lose invoices:read, the events you already received are ids you can no longer dereference. Your system never holds invoice data that TOVR would not show you today. A refetch also always returns the current state, so you never act on a copy that is hours old.

invoice.recorded fires once, when TOVR first records an invoice. What it means:

  • Recorded, not issued. The customer's ERP issued the invoice. TOVR found out about it.
  • A correction emits nothing. If the ERP re-sends the same invoice number with a new amount, TOVR updates the record and does not send another event. Re-read the record before you make an offer. Do not rely on the copy you fetched when the event arrived.
  • The covered transports can grow, and that emits nothing either. One ERP invoice can cover several transports. TOVR records them one at a time: the first transport invoiced under a given invoice number creates the record and fires invoice.recorded; every later transport under the same number is added to that record without an event. So the covers you read on the refetch is complete only as of that refetch. For a factor this is the one that matters: re-read GET /v1/invoices/{id} at the moment you make an offer or advance funds, never the covers you stored when the event arrived, or you may finance an invoice on an incomplete list of transports.
  • The record has no due date, payment state or PDF. Those do not exist in TOVR. The record and its fields are described in Invoices.

Subscribing to invoice.recorded

Create one endpoint per organization, using that organization's token:

curl -X POST https://tovr.eu/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://hooks.factor.example/tovr",
    "description": "invoice.recorded for customer 1042",
    "enabled_events": ["invoice.recorded"]
  }'

The 201 response is the endpoint (id is whe_…, plus status, enabled_events and signing_secret_hint). It also contains secret, a whsec_… string. This is the only time TOVR returns the secret. Store it against this organization before you do anything else. You need webhooks:write, and subscribing to invoice.recorded also needs invoices:read. An endpoint can deliver only data your credential could already read.

Several organizations can point at the same URL. Each organization's endpoint has its own secret. Use the tovr-webhook-id header on each delivery to choose which secret to verify with.

Endpoint requirements: https on port 443, a public address, no credentials in the URL. TOVR does not follow redirects. More detail, including changing enabled_events with PATCH, is in Webhooks.

Rotating the secret

curl -X POST https://tovr.eu/api/v1/webhook_endpoints/whe_…/rotate_secret \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"overlap_seconds": 86400}'

The response contains the new secret (once) and previous_expires_at. Until that time, every delivery is signed with both secrets, so you can deploy the new secret without dropping a delivery. The default overlap is 86,400 seconds (24 hours) and the maximum is 604,800 (7 days). If a secret leaks, send {"overlap_seconds": 0} to stop using the old secret immediately.

Verifying a delivery

Each delivery is an HTTPS POST whose body is the event above, with these headers:

content-type:          application/json
user-agent:            TOVR-Webhooks/1.0
tovr-signature:        t=1789716000,v1=83927e2aae1fd2caf10d118cd3e41f51aa0bc70b72cf6f382e3e0df9dd169f4a
tovr-event-id:         aev_5f0c1d2e-3a4b-4c5d-8e6f-708192a3b4c5
tovr-event-type:       invoice.recorded
tovr-webhook-id:       whe_…
tovr-delivery-attempt: 1

The signature scheme:

  • t is the Unix time in seconds when TOVR sent the delivery.
  • The signed string is `${t}.${rawBody}`: the timestamp, a full stop, then the body bytes exactly as sent.
  • Each v1 is HMAC-SHA256 of that string, written as lowercase hex. The key is the whole secret string, including whsec_, used as UTF-8 bytes. Do not base64-decode it.
  • The header has one v1= per active secret. During a rotation it has two. Accept the delivery if any of them matches.
  • Reject the delivery if t is more than 300 seconds from your clock. t is part of the signed string, so an attacker cannot replay an old delivery with a new timestamp.

Worked example

With the secret whsec_sandbox_example_do_not_use_0000000000000, the timestamp 1789716000 and this exact body (one line, no trailing newline):

{"object":"event","id":"aev_5f0c1d2e-3a4b-4c5d-8e6f-708192a3b4c5","type":"invoice.recorded","object_type":"invoice","object_id":"inv_0b6f4a8e-5c1d-4e2f-9a7b-3c8d2e1f0a9b"}

the signature is v1=83927e2aae1fd2caf10d118cd3e41f51aa0bc70b72cf6f382e3e0df9dd169f4a. Reproduce it from a shell:

SECRET='whsec_sandbox_example_do_not_use_0000000000000'
BODY='{"object":"event","id":"aev_5f0c1d2e-3a4b-4c5d-8e6f-708192a3b4c5","type":"invoice.recorded","object_type":"invoice","object_id":"inv_0b6f4a8e-5c1d-4e2f-9a7b-3c8d2e1f0a9b"}'
printf '%s' "1789716000.$BODY" | openssl dgst -sha256 -hmac "$SECRET"
# SHA2-256(stdin)= 83927e2aae1fd2caf10d118cd3e41f51aa0bc70b72cf6f382e3e0df9dd169f4a

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

/**
 * header:  the `tovr-signature` request header
 * rawBody: the request body EXACTLY as received (a string), before any JSON parsing
 * secret:  the whole `whsec_…` string, used as-is as the HMAC key
 */
export function verifyTovrSignature(header, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  const parts = header.split(",").map((p) => p.trim());
  const tPart = parts.find((p) => p.startsWith("t="));
  if (!tPart) return false;

  const t = Number(tPart.slice(2));
  if (!Number.isFinite(t)) return false;
  if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
    "utf8",
  );

  // During a secret rotation there are TWO v1= values. Accept if any matches.
  return parts
    .filter((p) => p.startsWith("v1="))
    .some((p) => {
      const actual = Buffer.from(p.slice(3), "utf8");
      return actual.length === expected.length && timingSafeEqual(actual, expected);
    });
}

With Express, read the body raw on this route: express.raw({ type: "application/json" }), then req.body.toString("utf8"). If express.json() parses the body first, re-serializing it gives different bytes and the signature never matches.

The length check before timingSafeEqual matters. timingSafeEqual throws when the lengths differ, so without the check a malformed header would crash your handler instead of failing verification.

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_tovr_signature(header: str, raw_body: bytes, secret: str, now: int | None = None) -> bool:
    """header: the `tovr-signature` header; raw_body: the request body bytes as received;
    secret: the whole `whsec_…` string, used as-is as the HMAC key."""
    now = int(time.time()) if now is None else now
    parts = [p.strip() for p in header.split(",")]
    t_part = next((p for p in parts if p.startswith("t=")), None)
    if t_part is None:
        return False
    try:
        t = int(t_part[2:])
    except ValueError:
        return False
    if abs(now - t) > TOLERANCE_SECONDS:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # During a secret rotation there are TWO v1= values. Accept if any matches.
    return any(
        hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1=")
    )

If verification fails on every delivery, check your server's clock before you check your code.

Handling a delivery

  1. Verify the signature. If it fails, return 400 and do nothing else.
  2. Deduplicate on tovr-event-id. Delivery is at-least-once, so you can receive the same event more than once.
  3. Return a 2xx quickly. TOVR stops waiting after 10 seconds and counts the attempt as failed. Put the event on your own queue and do the refetch there.
  4. Refetch GET /v1/invoices/{object_id} with that organization's token.
  5. Ignore any event type you do not handle, and still return 2xx (see Forward compatibility).

Deliveries are not ordered. You do not need an order: each event tells you to refetch, and a refetch returns the current state.

Retries and disabling

Any 2xx counts as delivered. Anything else is a failure: another status, a timeout, a TLS error or a redirect. After a failure TOVR retries on this schedule:

AttemptDelay after the previous attempt
1immediate
230 seconds
32 minutes
4~10 minutes
5~1 hour
6~6 hours
7~24 hours

The delays from 10 minutes up vary by ±10%, so a recovering endpoint does not receive all its retries at once. After the 7th attempt, about 31 hours after the first, TOVR gives up on that delivery.

  • Five deliveries in a row that use up all 7 attempts disable the endpoint. That is about a week of a genuinely broken endpoint. A deploy or a short outage will not disable it.
  • Returning 410 Gone disables the endpoint immediately and permanently. Use it only when you really mean "stop".
  • GET /v1/webhook_endpoints/{id} shows status, disabled_reason, consecutive_failures, last_error_code and last_error_message.
  • To re-enable, PATCH the endpoint with {"status": "active"}. Events that arrived while it was disabled are held for 14 days and delivered after you re-enable it. This does not apply to an endpoint disabled by a 410. The response also contains replay_from, a change feed cursor for the moment the endpoint went down.

The full rules are in Webhooks › Delivery semantics.

Catching up after downtime

The change feed holds the same events as webhooks for 30 days. After an outage, page through it from the last cursor you saved, or from replay_from:

curl "https://tovr.eu/api/v1/events?type=invoice.recorded&after=$CURSOR&limit=100" \
  -H "Authorization: Bearer $TOKEN"
  • Save next_cursor after every page, and send it back as after. In the feed, next_cursor is never null, even on an empty page.
  • ?type=invoice.recorded limits the page to invoice events. Without it, events:read also returns the organization's other event types (shipments, chains, …) as ids only. Your other scopes decide whether you can dereference them.
  • A cursor older than the 30-day window returns 410 with code: "cursor_expired", not an empty page. To recover, walk GET /v1/invoices (newest issued_at first) until you reach invoices you already have. Then continue from the next_cursor of a fresh GET /v1/events.

Events for different organizations come from different credentials, so keep one cursor per organization.

Rate limits and quotas

TOVR meters every API call before it runs it, per client application, against two limits from the organization's plan:

PlanRequests / minuteRequests / month
Hobby (the default)6010,000
Pro300200,000
Scale1,2002,000,000
Enterprisenot meterednot metered

The two limits fail differently. Handle them differently.

StatuscodeWhat to do
Per-minute limit429rate_limitedWait Retry-After seconds, then retry.
Monthly quota402quota_exceededStop. Waiting does not help until the next calendar month. The organization has to upgrade its plan.

A refused request still counts against both limits, so a loop of fast retries makes things worse. Every response has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (and the unprefixed RateLimit-* forms), so you can slow down before you reach the limit. Webhook deliveries do not count against your limits. Only your calls to TOVR count.

A typical factoring integration fits easily in the Hobby plan: one token per hour, one refetch per invoice, and a feed poll only after downtime. Most organizations stay on Hobby, so design for 60 requests per minute per organization. See API conventions › Rate limiting.

Forward compatibility

type and object_type are open vocabularies. TOVR adds new event types and new object types without a new API version, and a consumer that treats either as a closed list will break when that happens. invoice itself was added on 2026-09-17. So:

  1. Ignore an event whose type you do not recognize. Return 2xx for a webhook. In the feed, skip it.
  2. Advance your cursor past it anyway. A consumer that throws before it saves the cursor stops at the first unknown event and never receives anything after it, including the invoice.recorded events it does understand.
  3. If you generate a client from the OpenAPI document, make sure enum fields accept unknown values.

Each change to the API is recorded, with what to do about it, in apps/web/lib/api/versions/.

What TOVR does not do

  • TOVR does not issue freight invoices. The customer's ERP issues them, and TOVR records that an invoice exists. TOVR has no due date, payment status or document for an invoice, and cannot tell you whether it has been paid.
  • TOVR does not send you carrier personal data. Webhooks carry an event id and an invoice id, nothing more. You get only what the organization's scopes let you read, and only after that organization gave you a credential. TOVR has no bulk export, no carrier list and no cross-organization feed.
  • Factoring figures shown to carriers in TOVR are indicative. Where TOVR shows a carrier an estimated fee and net amount, TOVR calculates it from your published rate card and labels it as indicative. It is not an offer. Your published calculator describes its own output the same way. Your agreement with the carrier sets the real rate after you have assessed the debtor.
  • TOVR does not submit applications to you. Today the carrier goes to your application page through an ordinary link and applies there. TOVR does not pre-fill anything or send any data with that link.

What TOVR would need from you

Today TOVR can take a carrier only as far as your application page (a plain link). Going further needs something on your side. Neither of the following exists at Smart Factoring today.

Level 2: a deep link that accepts pre-filled parameters. A URL that opens your application with the invoice amount, currency and payment term filled in, plus a partner reference so you can see the application came through TOVR. This is the smallest step. For Smart Factoring it needs server-side work: their client portal is an ASP.NET WebForms postback page (SmartFactoringXpress.aspx), and that kind of page cannot read pre-filled values from a URL without changes on their server. None of the application pages we could reach has a referral or partner field.

Level 3: an API for eligibility (would you take this debtor, amount and term?), offer (a firm quote), submission (the invoice and CMR, with the carrier's consent) and status (approved, funded, declined). With this, TOVR could show a carrier a firm offer and its status instead of an indicative figure. We found no public API, webhook or single sign-on surface on Smart Factoring's portal or marketing site. /api and /swagger/index.html on the portal host return 200, but only because every unknown path redirects to the portal's start page.

The evidence and the date it was checked are in TOVR's research note (specs/products/factoring/00-smart-factoring-research.md, §4). If you build either level, the integration on TOVR's side is small, and we would like to hear from you.

Try it in the sandbox

TOVR can provision a sandbox organization on staging for you. It has:

  • a client application with invoices:read, events:read, webhooks:read and webhooks:write;
  • recorded invoices that have already produced invoice.recorded events, so GET /v1/events?type=invoice.recorded returns data straight away;
  • a way for TOVR to record new invoices on request, so you can watch a live delivery reach your endpoint.

The sandbox is on the Hobby plan, so you meet the real rate limits. Ask your TOVR contact for credentials. Then, from start to finish:

  1. Mint a token against https://staging.tovr.eu/api/v1/oauth2/token.
  2. GET /v1/invoices to check that you can read records.
  3. POST /v1/webhook_endpoints with enabled_events: ["invoice.recorded"] and store the secret.
  4. Ask TOVR to record a new sandbox invoice. Verify the delivery with the code above and refetch the invoice.
  5. Take your endpoint down, ask for another invoice, bring the endpoint back, and catch up from GET /v1/events.

On this page