Inbound: External Software → Factorial

How a partner drives an inbound sync run: open the run, declare what the external system holds, write the data, report each row.

💡 Context

Inbound is the synchronization or import process that extracts data from an external software, and introduces it into Factorial. This is the opposite flow to outbound, where data is synced out of Factorial: the client defines the data, clicks Sync, and the partner pushes it to the external software.

In inbound, Factorial does not know what exists on the other side, so it does not send you anything to push. Instead, after a sync run is opened, you need to discover the records in the external system, write them into Factorial through the public API, and report the outcome of each one.

✨ Introduction

Factorial's only job in an inbound run is bookkeeping: it opens the run, notifies you that it started, aggregates the statuses you report, and closes the run once every row reaches a final state.

A summary of the flow:

  1. Partner opens a sync run in Factorial, or the client triggers the sync from Factorial's UI
  2. Partner reads the external system and declares how many records it found
  3. Partner writes the business data through Factorial's regular endpoints
  4. Partner reports the outcome of each record
  5. Factorial derives the run status and closes it, so the client has visibility of the result of the sync
❗️

Sync run timeout

Please note all described steps are mandatory for the partner to be implemented. A run that is opened and never reported stays open and blocks the next one until Factorial reaps it after 1 hour.

🔐 Authentication

Inbound runs are OAuth only.

The access token identifies both the company and the integration: it resolves to the OAuth application registered for your integration, which carries the integration UUID stamped at registration time, and the token is already scoped to one company. That is why you never send either of them in a request body.

❗️

API keys cannot drive an inbound run

An API key is created by an admin of the customer's company and is scoped to that company, but it says nothing about which integration is calling. A run opened with one cannot be attributed to your integration, so use OAuth 2.

🔧 Setup

Setup is what an admin does from the marketplace, not something the partner calls:

  • The integration must be installed for the company.
  • The client authorizes your integration, which is what yields the OAuth token you will use.
  • The syncable types the integration declares (compensations, expenses…) are agreed with the Factorial team, same as for outbound. Check Syncable states endpoint.

Unlike outbound, there is no data-fields negotiation: Factorial requests nothing from you. The external system is the one that declares what it found.

🔄 Inbound Sync Flow

0. Know the one-open-run rule

Only one run can be open per company + integration + origin. If the previous run on the same origin is still running, creating a new one fails with a validation error.

❗️

Runs must be finished

This is the most common error when integrating. If a run was left open, either close it (step 4) or wait for the 1-hour timeout. The guard is keyed by origin, so a run you opened through the API never blocks a sync the client triggers from the Factorial UI, and vice versa.

1. Open the Sync Run

Creates a Sync run

POST {baseUrl}/api/{version}/resources/integrations/sync_runs

The endpoint takes no parameters — the OAuth token already says which integration and which company:

The response carries the id you will use for every following call:

{
  "id": 42,
  "company_id": 1,
  "integration_uuid": "your-integration-uuid",
  "status": "running",
  "started_at": "2026-10-01T09:00:00.000Z",
  "finished_at": null,
  "context": null
}

The run is created empty — no rows. That is expected: you have not told Factorial what exists yet.

📘

You also get a "run started" notification

Opening a run dispatches a notification to the target URL registered for your integration, the same mechanism outbound uses. Runs opened by the client from Factorial's UI reach you exactly the same way — that notification is your trigger to start reading the external system.

2. Declare what you found

Read the external system, then create one row per record. All of them born running.

Bulk upserts a Syncable sync run

POST {baseUrl}/api/{version}/resources/integrations/syncable_sync_runs/bulk_upsert

{
  "sync_run_id": 42,
  "items": [
    {
      "external_identifier": "ERP-1",
      "syncable_type": "expenses/expense",
      "status": "running"
    },
    {
      "external_identifier": "ERP-2",
      "syncable_type": "expenses/expense",
      "status": "running"
    }
  ]
}

Rows are matched by (sync_run_id, external_identifier, syncable_type), so calling bulk_upsert again with the same triplet updates the row instead of creating a second one.

❗️

Important

Declaring the rows as running is mandatory, not a formality. It is what prevents the run from closing before you are done, and what lets it close automatically once the last row reaches a final state. If you report rows already finished, the run closes on the first batch.

⚠️

syncable_type format

Use the namespace/resource form — expenses/expense, compensations/compensation, finance/vendor — the same value GET /syncable_items returns. Internal spellings you may find elsewhere (expensesexpensable, expenses/expensable) are not public and are not interchangeable with it: for expenses and finance they do not even agree.

3. Write the business data

Create the records through Factorial's regular domain endpoints — the same ones any API consumer uses. For example, POST /resources/expenses/expensables returns the new record:

{ "id": 424242 }

Keep that id: it is what links your external record to the Factorial one in the next step.

❗️

Store pointers, not payloads

bulk_upsert carries statuses only, never payloads. The framework stores pointers; the business data lives in the domain's own resources. Do not try to send the record itself through the framework endpoints.

4. Report the outcome of each record

Call bulk upsert again with the same (external_identifier, syncable_type) and a final status:

{
  "sync_run_id": 42,
  "items": [
    {
      "external_identifier": "ERP-1",
      "syncable_type": "expenses/expense",
      "status": "success",
      "syncable_id": 424242
    },
    {
      "external_identifier": "ERP-2",
      "syncable_type": "expenses/expense",
      "status": "invalid",
      "error_messages": { "base": "Missing cost center" }
    }
  ]
}
StatusWhen to use it
successThe record was written into Factorial. Send syncable_id with the created record's id.
failedSomething went wrong and the record was not written.
invalidThe data is structurally incorrect or missing required fields.
  • syncable_id is only accepted on success rows, and it only fills a missing link — re-pointing a row at a different record is rejected by design.
  • syncable_id is a string, like every id in this API. Send the id the domain endpoint returned, as a string.
  • error_messages is a free-form object of { key: message }, shown to the client.

5. Factorial closes the run

Once no row is left running, Factorial derives the run status. You never set it yourself:

RowsRun status
All successsucceeded
Some errored, some succeededsuccededwitherrors
All errorederrored

Both failed and invalid count as errored.

Reading the result

Reading back the outcome takes two calls, because the rows and the identifiers live in different resources.

First, the rows of the run — status and error messages:

Reads all Syncable sync runs

GET {baseUrl}/api/{version}/resources/integrations/syncable_sync_runs?sync_run_ids[]=42

[
  { "id": "9001", "sync_run_id": "42", "syncable_state_id": "7001", "status": "success", "error_messages": [] },
  { "id": "9002", "sync_run_id": "42", "syncable_state_id": "7002", "status": "invalid", "error_messages": [{ "key": "base", "value": "Missing cost center" }] }
]

This tells you how each row ended, but not which of your records it was: external_identifier is not exposed on this resource.

Then, resolve the identities through the syncable states the rows point at, using the syncable_state_id you just got:

Reads all Syncable states

GET {baseUrl}/api/{version}/resources/integrations/syncable_states?ids[]=7001&ids[]=7002

[
  { "id": "7001", "external_identifier": "ERP-1", "syncable_id": "424242", "resource_syncable_type": "expenses/expense", "status": "synced" },
  { "id": "7002", "external_identifier": "ERP-2", "syncable_id": null, "resource_syncable_type": "expenses/expense", "status": "invalid" }
]

Join both on syncable_sync_run.syncable_state_id = syncable_state.id to map every outcome back to your own external_identifier.

📘

syncable_states is also the durable view

A syncable sync run belongs to one run; the syncable state is the current link between your record and the Factorial one, and survives across runs. To check what you have already synced — regardless of which run did it — query it directly by integration:

GET {baseUrl}/api/{version}/resources/integrations/syncable_states?integration_uuid={your-uuid}

If you filter by syncable_ids[], you must also send resource_syncable_type (namespace/resource, e.g. expenses/expense).

And the runs themselves. To re-read the run you just opened, use its id:

GET {baseUrl}/api/{version}/resources/integrations/sync_runs/42

To list runs, both query parameters are mandatory:

GET {baseUrl}/api/{version}/resources/integrations/sync_runs?marketplace_integration_uuid={your-uuid}&created_at_gteq=2026-10-01

❗️

1-hour timeout

One hour after the run is opened, any row still running is marked as failed and the run is closed in its corresponding final state. This releases the one-open-run guard.

A run that reports nothing stays open until that timeout. If the external system had nothing to sync, do not leave the run silent — report it so the run can close.

📌 API versions

VersionInbound support
2026-10-01Full inbound: POST sync_runs, bulk_upsert, and the read endpoints.
2026-07-01Read side only (GET syncable_sync_runs). A run cannot be driven.

🔀 Inbound vs. outbound at a glance

Outbound (Integrations Framework)Inbound (this page)
Who starts itThe client clicks Sync; you receive a webhookYou open the run, or the client triggers it
Who knows what existsFactorial — you GET syncable_itemsYou — you declare the rows
Where the data goesYou push it to the external softwareYou write it into Factorial
ReportingPUT syncable_sync_runs/{id}, one by onebulk_upsert, in batches
Rows start asCreated by FactorialCreated by you, as running

⚠️ Troubleshooting

SymptomCause
The run is created but comes back errored immediately, with no rowsYour integration has no inbound capability registered for API-opened runs. The run is not timing out — it is being terminated at dispatch. Contact the Factorial team: this is a registration issue, not something you can fix from the API.
Creating a run fails with "a run is already running"One open run per company + integration + origin. Close its rows or wait for the 1-hour timeout.
403 or the run is not attributed to youYou authenticated with an API key. Inbound runs are OAuth only.
syncable_type rejected, or matches nothingYou sent an internal spelling (expensesexpensable, expenses/expensable) instead of the public expenses/expense.
The run closed immediately, before you finishedRows were reported with a final status instead of running in step 2.
The run never closesSome rows are still running, or the run has no rows at all. Both wait for the timeout.
A row has no link to the Factorial recordsyncable_id was sent on a non-success row, omitted on the success row, or the record is not visible to your credential.
Re-linking a row is ignoredBy design: syncable_id only fills a link that is missing.
404 on POST sync_runs or bulk_upsertYou are on an API version older than 2026-10-01.

❓ Questions

For anything not covered here, use the Technical support form.


Did this page help you?