Skip to Content
ReferenceEvent APILegacy-compatible ingress

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 } } }
FieldTypeRequiredDefaultNotes
auth_headerstringyesThe header the caller sends its key in. The key itself is never in the config, see Compat keys.
max_entitiesintegerno500Largest entities array accepted in one call, 1 to 500.
routesarray of RouteyesAt least one. Paths must be unique.
resend_actionsobjectno{}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.
fallbackobject | nullnonullGrace-period forwarding to the legacy service, see Fallback.

Routes

FieldTypeRequiredDefaultNotes
pathstringyesThe legacy path, e.g. api/upsertorders.
eventstringone of the twoFixed event type for every call on this path.
event_fieldstringone of the twoDotted path in the request body the event type is read from.
eventsarray of stringwith event_fieldThe 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’s entities; 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:

MethodPathEffect
POST/api/connectors/{connector_id}/compat-keysImport a key. Body {"secret": "...", "label": "..."}. Organization owner or organization API key.
GET/api/connectors/{connector_id}/compat-keysList 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": "..."}.

StatusBodyWhen
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.
400result: "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.
423result: "error"The connector is in a status that blocks ingestion, or the organization is suspended or being closed.
429result: "error", Retry-After headerToo 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" } ] }
FieldNotes
createdAt, updatedAtISO 8601 UTC with milliseconds. updatedAt moves when the call is resent.
callRequestThe 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.
callStatusOne of InQueue, InProgress, Error, Complete, see below.
callErrorOne 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.

EventOutcome
pending (accepted, not picked up yet)queued
processingrunning
completeddone
skipped (no flow matched the event: nothing to do)done
partial_failure, or any record of the event reported as failederror
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 hourserror

Then, with the legacy precedence (work in flight wins over an error, an error wins over a completion):

Events of the callcallStatus
every event queuedInQueue
at least one queued or runningInProgress
none in flight, at least one errorError
every event doneComplete

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

StatusBodyWhen
404{"result": "error", "message": "callId not found"}The connector never issued this callId (unless the fallback is on).
410result: "error", message starting with callId expiredThe call is known but its events are past the retention window.
401, 429as for ingestSame 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 503 are 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.

StatusBodyWhen
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.
409result: "error"The callId is known and the action does not name the call’s event type. Nothing is queued.
410result: "error", message starting with callId expiredThe call is older than the payload retention (30 days), or its stored payloads are gone. Nothing is queued.
423, 429as for ingestConnector 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_actions is 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_url must be https, 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 answers 404. A base_url that points back at a legacy-compatible ingress therefore cannot loop.
  • With enabled: false (or no fallback) no outbound call is ever made and an unknown callId answers 404.

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.

Last updated on