Legacy-compatible ingress
A connector can accept the HTTP contract of the service it replaces, as it is: the caller keeps its paths, its key, its header, its request envelope and its response shape, and only changes the base URL. This is declared in the connector config as an ingress profile and is meant for cutovers where the calling system cannot be changed.
Every call accepted this way becomes ordinary Event API events on the
event_api source, so the connector’s flows match them exactly like events
sent to POST /api/v1/events (see the Event API).
Without a published profile the connector has no compat surface: every path
below answers 404.
Base URL
https://app.plugsync.com/api/v1/compat/{connector_id}/<legacy path>All calls are POST. Paths are matched without regard to case or to
leading and trailing slashes.
The ingress profile
ingress_profile is a top-level key of the connector config. It follows the
draft and publish workflow like every other key: it takes effect when the
draft is published and is part of the version history and of the diff.
In the dashboard the profile is edited from the connector workspace, under Legacy ingress: authentication header, routes, resend actions and fallback. A value the schema refuses is reported on its field and nothing is saved. Saving writes to the draft only; the change is listed in “Review changes” and goes live with the publish. A connector without a profile shows an empty state there and accepts no legacy call. The same key can be written through the draft API or the CLI, in the shape below.
{
"ingress_profile": {
"auth_header": "API_KEY",
"max_entities": 500,
"routes": [
{ "path": "api/upsertorders", "event": "order_upserted" },
{ "path": "api/upsertcustomers", "event": "customers_upserted", "split": "none" },
{ "path": "shop/webhook", "event_field": "event",
"events": ["form_contacts", "form_newsletter"] }
],
"resend_actions": {
"update_order": "order_upserted",
"update_customer": "customers_upserted"
},
"fallback": { "base_url": "https://legacy.example.com", "enabled": false }
}
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
auth_header | string | yes | The header the caller sends its key in. The key itself is never in the config, see Compat keys. | |
max_entities | integer | no | 500 | Largest entities array accepted in one call, 1 to 500. |
routes | array of Route | yes | At least one. Paths must be unique. | |
resend_actions | object | no | {} | Action name to the event type it resends, or to a list of event types, see Resend a call. Empty: no call issued by this connector can be resent. |
fallback | object | null | no | null | Grace-period forwarding to the legacy service, see Fallback. |
Routes
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
path | string | yes | The legacy path, e.g. api/upsertorders. | |
event | string | one of the two | Fixed event type for every call on this path. | |
event_field | string | one of the two | Dotted path in the request body the event type is read from. | |
events | array of string | with event_field | The values accepted in event_field. Anything else answers 400. | |
split | "entities" | "none" | no | "entities" | How one call becomes events, see below. |
split decides what a flow receives:
entities: one event per item of the envelope’sentities; the event’s data is that item, read in a flow as$.payload.<field>.none: one event for the whole call; the event’s data is the whole body, for flows that walk the list themselves ($.payload.entities).
The paths api/check/<callId> and api/resend/<action>/<callId> are
answered by the compat surface itself and cannot be declared as routes.
Parity note: event versus event_field
A route with a fixed event ignores the event field of the request body.
If the legacy service chose what to do from the body’s event on every path
(not only on a webhook path), a fixed event is not equivalent: a call
carrying an unexpected event would be accepted and processed as the path’s
event. For strict parity declare those routes with event_field: "event"
and list the accepted values in events. Keep a fixed event for paths
whose legacy handler never looked at the body.
Compat keys
The caller keeps the key it already has. Import it once as a compat key of
the connector; it is stored only as a hash, is never returned by any
endpoint, and authenticates the compat paths only (not POST /api/v1/events
nor the management API).
An organization owner imports and revokes keys from the same Legacy ingress section of the connector workspace. The value is typed once and is not displayed again: the list shows the label, the status and the dates. Import and revoke take effect immediately, without a publish, and a revoke asks for confirmation. The same operations are available over the API:
| Method | Path | Effect |
|---|---|---|
POST | /api/connectors/{connector_id}/compat-keys | Import a key. Body {"secret": "...", "label": "..."}. Organization owner or organization API key. |
GET | /api/connectors/{connector_id}/compat-keys | List keys: id, label, is_active, created_at, last_used_at. |
DELETE | /api/connectors/{connector_id}/compat-keys/{key_id} | Revoke a key. Calls carrying it answer 401 from then on. |
curl -X POST https://app.plugsync.com/api/connectors/$CONNECTOR_ID/compat-keys \
-H "x-api-key: $ORG_API_KEY" \
-H "content-type: application/json" \
-d '{"secret": "the-key-the-caller-already-sends", "label": "legacy caller"}'The secret is taken verbatim, 8 to 512 characters. Importing a value the
connector already holds answers 409, whether that key is active or
revoked: a revoked key is not reactivated by importing it again.
Send a call
curl -X POST https://app.plugsync.com/api/v1/compat/$CONNECTOR_ID/api/upsertorders \
-H "API_KEY: the-key-the-caller-already-sends" \
-H "content-type: application/json" \
-d '{
"version": "1.0",
"source": "shop",
"event": "upsertorders",
"timestamp": "2026-09-30T10:00:00Z",
"entities": [
{ "orderId": "O-00001", "total": 11.5 },
{ "orderId": "O-00002", "total": 12.5 }
]
}'{ "result": "ok", "callId": "5f0c6c0e-8a3e-4d5b-9d55-0b6a3c1f7a21" }The callId identifies the call for check and
resend. timestamp (ISO 8601 or epoch) is optional and
becomes the time the events occurred; without it the arrival time is used.
Responses
Every response has the legacy shape. Errors are
{"result": "error", "message": "..."}.
| Status | Body | When |
|---|---|---|
200 | {"result": "ok", "callId": "<uuid>"} | The call was accepted and its events queued. |
201 | {"result": "ok", "callId": "No entities to process"} | entities is empty or absent. Nothing was created; this value is not a callId that can be checked. |
400 | result: "error" | Body is not a JSON object, entities is not an array of objects, or the event_field value is not in events. |
401 | {"result": "error", "message": "Invalid <auth_header>"} | Missing or wrong key. |
404 | {"result": "error", "message": "Not found"} | No published profile, or no route for the path. |
423 | result: "error" | The connector is in a status that blocks ingestion, or the organization is suspended or being closed. |
429 | result: "error", Retry-After header | Too many failed authentications from the same address (20 in 15 minutes, counted across ingest, check and resend), the organization’s per-second rate limit, its monthly event quota, or a flow paused by its health breaker. |
500 | {"result": "error", "message": "A maximum of <n> messages can be sent"} | More than max_entities entities. The status is the legacy one, kept for parity. |
503 | {"result": "service_unavailable", "message": "...", "callId": "<uuid>"} | Some or all events could not be queued. The call is recorded: resend it by its callId to queue what is missing. |
Calls count toward the organization’s event quota like Event API events, one per event produced.
Check a call
POST /api/v1/compat/{connector_id}/api/check/{callId}Same key, no body.
{
"result": "ok",
"callId": "5f0c6c0e-8a3e-4d5b-9d55-0b6a3c1f7a21",
"createdAt": "2026-09-30T10:00:01.482Z",
"updatedAt": "2026-09-30T10:00:01.482Z",
"callRequest": {
"version": "1.0",
"source": "shop",
"event": "upsertorders",
"timestamp": "2026-09-30T10:00:00Z",
"entities": [
{ "orderId": "O-00001", "total": 11.5 },
{ "orderId": "O-00002", "total": 12.5 }
]
},
"callStatus": "Error",
"callError": [
{ "externalObjectId": "O-00002", "error": "Property total is not a number" }
]
}| Field | Notes |
|---|---|
createdAt, updatedAt | ISO 8601 UTC with milliseconds. updatedAt moves when the call is resent. |
callRequest | The request as it is still held. For a split: "entities" route: the envelope fields version, source, event, timestamp plus entities in their original order (other top-level fields of the request are not kept). For a split: "none" route: the whole body. |
callStatus | One of InQueue, InProgress, Error, Complete, see below. |
callError | One object per record in error: error, and externalObjectId (a string) when the error belongs to a record. With no error it is [{}], as in the legacy contract, so a caller reading callError[0] keeps working. At most 500 items. |
Status mapping
callStatus aggregates the events the call produced (after a resend, the
events the resend queued). Each event is first read as one outcome. The
event statuses are the ones GET /api/v1/events/{event_id}/status reports.
| Event | Outcome |
|---|---|
pending (accepted, not picked up yet) | queued |
processing | running |
completed | done |
skipped (no flow matched the event: nothing to do) | done |
partial_failure, or any record of the event reported as failed | error |
failed (the event could not be queued, at ingest or at resend) | error |
drained (discarded before being processed) | error |
pending or processing for more than 8 hours | error |
Then, with the legacy precedence (work in flight wins over an error, an error wins over a completion):
| Events of the call | callStatus |
|---|---|
| every event queued | InQueue |
| at least one queued or running | InProgress |
| none in flight, at least one error | Error |
| every event done | Complete |
A caller that polls should stop on Complete or Error. On Error,
callError says which records failed; a resend re-runs the whole call.
The legacy value Failed is never returned: a call whose data has expired
answers 410 instead.
Errors
| Status | Body | When |
|---|---|---|
404 | {"result": "error", "message": "callId not found"} | The connector never issued this callId (unless the fallback is on). |
410 | result: "error", message starting with callId expired | The call is known but its events are past the retention window. |
401, 429 | as for ingest | Same key and same failed-authentication limit as the ingest paths. |
Resend a call
POST /api/v1/compat/{connector_id}/api/resend/{action}/{callId}Same key, no body. It re-runs the events of the call from the stored
payloads and answers {"result": "ok", "callId": "<callId>"}.
action must be a key of the profile’s resend_actions, and it must name
the event type of the call: a call made on the orders path is resent with
an action mapped to the orders event. The stored events are always re-run
as what they are; the action never changes how they are processed.
An action maps to one event type or to a list of them. Use a list when the caller resends calls made on different paths with the same action:
"resend_actions": {
"update_order": "order_upserted",
"update_product": ["products_upserted", "warehouse_status_upserted"]
}Action names are 1 to 64 characters among letters, digits, ., _ and
-. Every event type named must be one a route of the profile produces (a
route’s event, or one of the events of a route with event_field);
otherwise the configuration is refused when it is saved or published. The
check is skipped when a route has event_field without events, since that
route can produce any event type.
The checks run in this order: the shape of the action, then the callId,
then resend_actions. An unknown callId therefore answers 404 (or is
forwarded by the fallback) whatever the
action, and resend_actions only applies to callIds this connector issued.
What is queued, per event of the call:
- an event that reached a final status is re-run as a new event with the same payload, so its new outcome is reported by check and the earlier outcome stays in the event history. The same applies to an event that check reports as an error because no final outcome was reported for it (open for more than 8 hours);
- an event still open and younger than that (it could not be queued, or it
is pending or processing) is queued again as itself. This is how the events of a call
that answered
503are picked up.
Events keep the time they originally occurred. A resend of the same callId
within 5 minutes of the previous one answers ok without queueing again,
unless the previous one left events unqueued. A resend is not charged to the
event quota.
| Status | Body | When |
|---|---|---|
200 | {"result": "ok", "callId": "<callId>"} | The events were queued (or the resend repeats one made in the last 5 minutes). |
400 | {"result": "error", "message": "Invalid action"} | action is not shaped like an action name. |
404 | {"result": "error", "message": "callId not found"} | Unknown callId (unless the fallback is on), whatever the action. |
400 | {"result": "error", "message": "Unknown action '<x>'. Allowed: a, b, c"} | The callId is known and action is not in resend_actions. |
409 | result: "error" | The callId is known and the action does not name the call’s event type. Nothing is queued. |
410 | result: "error", message starting with callId expired | The call is older than the payload retention (30 days), or its stored payloads are gone. Nothing is queued. |
423, 429 | as for ingest | Connector status, rate limit, paused flow. |
503 | {"result": "service_unavailable", "message": "...", "callId": "<callId>"} | Some events could not be queued. Resend again. |
A resend never answers ok for a call it could not re-run.
Differences from a legacy service to check before a cutover
A legacy service may answer 200 to a resend for any callId, including
unknown or expired ones, and may run whatever processor the action names
even on a call of another kind. Here those cases answer 404, 410 and
409. If the caller resends on a schedule, look at which actions and
callIds it sends and make sure it tolerates these answers.
Fallback to the legacy service
During a cutover the caller may still ask about callIds issued by the legacy service. With
"fallback": { "base_url": "https://legacy.example.com", "enabled": true }a check or resend for a callId this connector does not know is forwarded to
<base_url>/api/check/<callId> or <base_url>/api/resend/<action>/<callId>
with the caller’s own key in the same header, and the legacy answer (status
and body) is returned. The answer is always served as
Content-Type: application/json with X-Content-Type-Options: nosniff,
whatever content type the legacy service declares; no other header of the
legacy answer is relayed.
- Only check and resend are forwarded, never an ingest call, and only after the caller authenticated with a valid compat key.
- A callId the connector knows is always answered here, never forwarded.
- A resend is forwarded with the action the caller sent:
resend_actionsis not consulted for a callId the connector does not know. - The forward does not depend on the state of the connector: it is made also when the connector is paused or the organization is suspended, since nothing is ingested.
base_urlmust behttps, without username, password, query string or fragment, and must resolve to public addresses. Redirects are neither followed nor relayed.- The legacy service has 5 seconds in total to answer, name resolution and
body included, and its answer must be uncompressed and at most 5 MB. If
it does not meet either limit, or answers with a redirect, the call
answers
503{"result": "service_unavailable", "message": "..."}. - Every forwarded call carries the header
X-Plugsync-Compat-Forwarded: 1. A check or resend that arrives with that header is never forwarded: an unknown callId answers404. Abase_urlthat points back at a legacy-compatible ingress therefore cannot loop. - With
enabled: false(or nofallback) no outbound call is ever made and an unknown callId answers404.
Turn the fallback off, by publishing the profile without it, once the legacy callIds are no longer asked about.
Retention
A call’s payloads follow the retention of Event API events (30 days): past
it, check and resend answer 410. A payload re-run by a resend starts its
own 30 days. The record of the call itself (its callId and status) is kept
for 90 days, after which the callId answers 404.