Skip to Content
ReferenceEvent APIOverview

Event API

The Event API is how an external system pushes events into a connector in real time: POST /api/v1/events accepts one business event, persists it, and queues it for processing. Flows subscribe to (source: "event_api", event_type) pairs, see the flow authoring guide.

This page covers the real-time path. For day-0 loads and large re-syncs (thousands of records in one shot) use the bulk import endpoint instead.

Authentication

Every request carries a connector API key in the x-api-key header. Keys are scoped to one connector and look like ps_live_.... A request without the header answers 401 Missing x-api-key header; a revoked or unknown key answers 401 Invalid or revoked API key. Organization keys and login tokens do not work on these endpoints.

Creating, listing, revoking and rotating connector keys is covered in Connector API keys.

Send an event

curl -X POST https://app.plugsync.com/api/v1/events \ -H "x-api-key: ps_live_..." \ -H "content-type: application/json" \ -d '{"event": "order_created", "data": {"order_id": "A-1001", "total": 129.9}}'

Request body

FieldTypeRequiredNotes
eventstringyesThe event type. Flows match on (source: "event_api", event_type); wildcards like order.* are declared on the flow, not here. Required with entities too, where it labels the request and flows match on each entry’s type instead, see The entities shape.
dataobjectyes*The business payload. Inside flow manifests this exact object is addressed as $.payload.*. It can carry a file as a base64 string, see File attachments.
timestampISO 8601 datetimenoThe business time the event occurred (not the send time). Used as occurred_at by most_recent conflict arbitration when no source_modified_path resolves. Naive datetimes are treated as UTC. Absent, the ingest instant is used.
storeIdstringnoMulti-brand routing. Can also be sent as the x-store-id header; falls back to the connector’s default store.
recordKeystring (max 256)noThe record this event is about, for example the order number. Once per-record ordering is enabled (it is being rolled out progressively), events with the same recordKey on the connector are processed in order and events with different keys in parallel; until then the key is accepted and has no effect. Only read with data. Absent, the key is taken from the payload when the flow’s identity mapping reads it directly, see Processing order and throughput.
entitiesarrayno*Multi-record shape (max 200): each entry carries its own externalObjectId and enqueues one {entity.type}_synced event, processed per record (today already, whatever the rollout described in Processing order and throughput). Entry fields and a complete request: The entities shape.

*Send either data (one payload, one event) or entities (one entry per record). The two are not interchangeable once volume matters: which one to send is a throughput decision, covered in Processing order and throughput. A stray payload key is rejected with a 422: the business payload goes under data; the $.payload.* spelling exists only inside flow manifests.

The entities shape

entities does not replace event: a request with entities and no event is rejected with a 422, like any other request missing a required field. Each entry of the array has its own required fields:

FieldTypeRequiredNotes
typestringyesThe record type. The entry is delivered to flows as the event type {type}_synced: an entry with "type": "contact" runs the flow declared on (source: "event_api", event_type: "contact_synced").
externalObjectIdstringyesYour stable id for the record. Inside flow manifests it is $.external_id, next to the payload and not inside it.
propertiesobjectyesThe business payload of this record. Inside flow manifests this exact object is addressed as $.payload.*.

A complete request, two records of two types:

curl -X POST https://app.plugsync.com/api/v1/events \ -H "x-api-key: ps_live_..." \ -H "content-type: application/json" \ -d '{ "event": "erp_export", "timestamp": "2026-03-01T10:15:00Z", "entities": [ { "type": "contact", "externalObjectId": "CUST-042", "properties": {"email": "jane@example.com", "firstname": "Jane", "lastname": "Doe"} }, { "type": "order", "externalObjectId": "A-1001", "properties": {"order_id": "A-1001", "total": 129.9} } ] }'

The response is one 202 with one event_id for the whole request and entities_count: 2. Each entry is then processed as its own event:

In the requestIn the flow that runs
entities[].type (contact)event_type is contact_synced
entities[].properties$.payload.* ($.payload.email, $.payload.firstname, …)
entities[].externalObjectId$.external_id
event (erp_export)Not matched by any flow. It is recorded with the request.
timestamp, storeIdShared by every entry of the request.

event + data and entities are mutually exclusive: a body carrying both is rejected with a 422.

Where the record id is read from

The two shapes put the record id in different places, and the entity configuration has to say which one it reads:

  • With entities, the id is $.external_id. It is not copied into $.payload, so a mapping that reads $.payload.id finds it only if you also sent id inside properties.
  • With event + data there is no $.external_id: the id is whatever field of data holds it, addressed as $.payload.<field>.

For a HubSpot target, the record is keyed on the value the entity’s field_mappings write onto its identity.property, so with entities map the id explicitly:

{"source": "$.external_id", "target": "erp_customer_id"}

A schema for any other target that declares identity reads the id through identity_source_path, and its default does not fit Event API events: set identity_source_path: "$.external_id" on it explicitly (or a $.payload.<field> path when you send event + data). Without it the step fails with an error naming the paths that were tried. See identity_source_path default and JSONPath conventions.

Response

202 Accepted: the event is queued, not yet processed:

{ "event_id": "8b1f...", "status": "processing", "entities_count": 1, "hint": null }

hint, when present, is an advisory string worth logging. It fires in the cases below; when several apply, their messages are joined in one string:

  • No subscribed flow: no published flow listens on (event_api, <your event_type>). The event is accepted but nothing will sync until a matching flow is published. This is computed against the published config, so a draft-only flow still triggers the hint.
  • Only a disabled flow: the only published flow subscribed to the event type is disabled. The event is accepted but nothing will sync until the flow is re-enabled and published.
  • Every subscribed flow is paused by its circuit breaker: after repeated fatal errors each subscribed flow has a tripped breaker that is ready for its recovery attempt (half-open, or open with its wait already elapsed). The event is accepted and held until the flow recovers or its breaker is retried (POST /api/connectors/{id}/flows/{flow_name}/retry). While a flow’s breaker is still inside its wait, the event is not accepted at all: the request answers 429 and the event has to be sent again.
  • Payload too large for the real-time path: an entities payload above 50 entries gets pointed at bulk import.
  • No record key: once per-record ordering is enabled, a data event for which no record key could be found (no recordKey, and no identity read from the payload) is processed in one sequence with the connector’s other keyless events. The hint names the event type and the two ways out, see Processing order and throughput. It never fires while per-record ordering is not enabled, nor on a connector or source that opted out with ordering: "connector".

Errors

StatusMeaningWhat to do
401Missing, malformed, or revoked API key.Check the x-api-key header; mint a new key if revoked.
422Body validation failed (e.g. a stray payload key, missing event).The detail message states the exact field.
423Ingestion is blocked. Either the connector is paused or in error (detail is a plain message, “Connector is paused, ingestion is blocked”), or the whole organization is stopped: detail.error is org_suspended (syncs suspended, contact support@plugsync.com) or org_closing (the organization is being closed; an owner can cancel the closure from the organization settings).Resume the connector from the dashboard, or act on the organization as detail.message says.
429Rate limited. Three distinct causes share this status. See 429 below.See below.
503A transient platform failure while accepting the event (detail.error is event_ingestion_unavailable) or storing its attachment (attachment_store_unavailable, see File attachments).Retry with backoff; nothing was enqueued. GET /api/health/data-plane says when ingestion is back.

These are the errors of the ingest call. Once an event is accepted with a 202, what happens to it if a flow step fails (how many times it is retried, which terminal state it lands in, how to replay it) is covered in Reliability: retry, failures, replay. Error codes of the other public routes are in the API error catalog.

429

Three unrelated conditions answer 429 Too Many Requests. As with every error of this API, the JSON body has a single detail key: for the per-second rate limit it is a plain string, for the other two it is an object whose error field tells them apart.

Per-second rate limit - the org sent more requests in the last second than its tier allows. It is checked before anything else, so it can answer even when the quota still has room:

{ "detail": "Rate limit exceeded for org" }

The response carries Retry-After: 1 and the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. The limit is counted per org over a sliding one-second window, across all of its connectors and API keys:

TierRequests per secondEvents per month
Free102,000
Trial10020,000
Starter2525,000
Pro5075,000
Scale100400,000
Enterprise100unlimited

Slow down and resend after a second; a client that sends in bursts should spread them out, or move a backfill to bulk import.

quota_exceeded - the monthly event quota for the org’s tier has been reached:

{ "detail": { "error": "quota_exceeded", "events_used_this_period": 25000, "tier": "starter", "upgrade_url": "https://app.plugsync.com/settings/billing", "period_resets_at": "2026-10-01T00:00:00Z" } }

Upgrade the tier, or wait for period_resets_at.

connector_flow_breaker_open - every flow that would process this event’s type is temporarily paused after repeated failures. The event was rejected, not queued: nothing was accepted for later processing, so it must be resent once the flow recovers. The Retry-After response header (seconds) says how long to wait before resending:

{ "detail": { "error": "connector_flow_breaker_open", "connector_id": "8b1f...", "flows": [ { "name": "order_sync", "reason": "transient_exhausted_streak", "detail": "associate_many: all 1 associations failed (unresolved endpoint)", "retry_after_seconds": 812 } ], "docs": "https://docs.plugsync.com/reference/event-api#429" } }

reason is one of fatal_streak (a run of unrecoverable failures), transient_exhausted_streak (a run of failures that gave up retrying), failure_rate (too many failures over a recent window), or manual (paused by an operator). detail is the last error the flow hit. docs is an absolute link back to this section, so an automated client can surface it without knowing the docs site layout. The flow resumes automatically as soon as one retried event succeeds; sending the event again after the Retry-After wait is exactly that retry. If it keeps recurring, open the connector’s Reliability view in the dashboard for the affected flow’s status and last error, and fix the underlying cause (a bad mapping, a target that is down) rather than just resending.

File attachments

An event can carry a file (typically the PDF of an offer or order) that a flow then attaches to a HubSpot deal with the upload_file step. The file travels inside data as a base64 string at the path the connector declares in settings.file_field:

curl -X POST https://app.plugsync.com/api/v1/events \ -H "x-api-key: ps_live_..." \ -H "content-type: application/json" \ -d '{"event": "offer_updated", "data": {"deal": {"id": "9000", "numerodoc": "9000/V2", "title": "Offer 9000"}, "linkfile": "JVBERi0xLjQKJ..."}}'

The contract of the field:

  • A bare base64 string, the encoded bytes of the file and nothing else. Not an object such as {"filename": ..., "content": ...} (a non-string value is left as it is, so no file is attached), and not a data URI: a data:application/pdf;base64, prefix is not recognized, the whole value is decoded as one string and the file comes out corrupted (or is dropped as invalid base64, see below).
  • Only on the single-event shape. The field is read from data on POST /api/v1/events. A base64 string inside an entities entry or a bulk import row is not set aside and reaches the flow as plain text.
  • The published config decides. Until a config declaring file_field is published, the field reaches the flow as the raw base64 string.

At ingest, before the event is queued, plugsync decodes the string, sets the file aside, and replaces the field with a small reference:

{ "deal": {"id": "9000", "numerodoc": "9000/V2", "title": "Offer 9000"}, "linkfile": { "s3_key": "<opaque reference>", "filename": "9000_V2.pdf", "numdoc": "9000", "version": "V2" } }

This is the data every flow step sees, addressed as $.payload.<file_field> (here $.payload.linkfile), and it is also the payload stored with the event: the base64 string itself is not kept there. s3_key is an opaque reference that upload_file and delete_attachments use to find the file; do not build it or rely on its format.

  • filename is always {numdoc}_{version}.pdf, where numdoc and version come from the file_numdoc_field / file_version_field settings (each optionally refined by its *_transform). It carries no timestamp on purpose: it is the idempotency key, so a redelivered event does not attach the same file twice. The extension is .pdf whatever the file is. When a part does not resolve it is empty (9000_.pdf, or _.pdf with neither). Two files with the same name are the same file to plugsync: a deal that already has a note carrying that name does not get the second one, even if its content differs. Give every document revision its own numdoc / version pair.

  • Invalid base64 (for example, wrong padding) is a data problem, not a platform one: the event is still accepted with 202, the field is set to null, and the deal syncs without the file.

  • The file cannot be stored: the call answers 503 and nothing is queued. Resend the same event after a backoff:

    {"detail": {"error": "attachment_store_unavailable", "message": "Attachment store unavailable, retry. ..."}}

The set-aside file is removed once upload_file has attached it to the deal (or found it already attached), and in any case after about two days: a flow that never runs upload_file does not keep it.

Processing order and throughput

Once per-record ordering is enabled for your environment (see the Rollout note below: today it is not everywhere), events are processed in order per record: events about the same record on the same connector run one after the other, in the order they were accepted, while events about different records run in parallel. The parallelism is bounded per connector: records are spread over a fixed number of sequences, 4 by default and set with settings.record_buckets, and a record always falls in the same sequence. Records that share a sequence wait for one another; each sequence runs at about one event per second, so a connector drains at most that many events per second, which is sized on what the target system’s rate limit absorbs anyway (the rows of an ingested file follow their own per-file rule, below). What decides the record is its key, and each shape finds it in its own way.

Rollout. Per-record ordering is being rolled out progressively. Until it is enabled for your environment, every event keeps the per-connector order that keyless events get (described below), with two exceptions that are already per record: the entities shape (by externalObjectId, one sequence per record, with no bound) and the rows of an ingested file (by row identity). In the meantime recordKey, a source’s id_jsonpath, the ordering setting and record_buckets are accepted, validated and published, but have no effect on the order, and the 202 carries no record-key hint. Setting them now is safe: they take effect when per-record ordering is enabled, with no change on your side. Until then, entities is the shape that parallelizes per record.

When per-record ordering is enabled, entities follows the same bound as every other shape: its records are spread over the connector’s record_buckets sequences instead of one sequence each. A connector that relies on entities for throughput then drains at most record_buckets events per second; raise record_buckets if the target system absorbs more.

entities: each entry’s externalObjectId is its record key.

event + data: the key is, in this order,

  1. the recordKey you send in the body;
  2. otherwise, the record’s identity read from the payload: when the flow subscribed to the event type upserts an entity whose HubSpot identity property is mapped straight from $.payload.* (the simple entity shape), the value of that field in data is the key. A contact flow whose email identity is mapped from $.payload.email is ordered by email with nothing to add to the request.

When neither gives a key, and only then, the event is ordered per connector: every keyless event of the connector forms one single ordered sequence, one event at a time, the next starting only when the previous one is done. Once per-record ordering is enabled, the 202 says so in hint (see Response). That sequence runs on the order of one event per second per connector, and it does not rise with the processing capacity behind it. Treat the figure as an order of magnitude rather than a contractual rate: one step of the sequence costs one full end-to-end pass of your own flow. Below that rate the difference is invisible; above it, give the events a key.

An identity mapped from a transformed value (the federated shape, which reads $.canonical.*) is not read at ingest, so a federated flow needs an explicit recordKey to run per record.

The same rule applies to the other ways events arrive: webhook deliveries and poller items are ordered by the value at the source’s id_jsonpath or, without it, by the identity read from the payload; HubSpot journal changes by the HubSpot record id; the rows of an ingested file by each row’s identity within the file (a row with no identity is ordered against no other row and runs in parallel with the rest of the file); scheduled ticks, which carry no record, stay per connector. See EventSources.

Choosing a key

  • A key identifies one record. Two events with the same key are kept in order; two events with different keys may complete in either order. A key that repeats across distinct records (a customer code on every order of that customer) serializes those records together: correct, only slower. Two different keys may also share a sequence (see the bound above): they then wait for one another, but their relative order is still not guaranteed. A key that changes for the same record (a request id, a timestamp) lets two updates of that record overtake each other.
  • A wider key keeps a group in order. When a flow depends on another record being processed first (an order that must find its customer), send the same recordKey on both events, for example the customer code: they share one sequence while every other customer runs in parallel.

Keeping the whole connector in one sequence

A connector whose flows depend on the order between different records, and cannot express it with a shared key, can opt out of per-record ordering: settings.ordering: "connector" (Settings panel, Event ordering) puts every event of the connector back in one sequence, keys included, at the per-connector rate above. A source can override the connector choice with its own config.ordering, see EventSources. The default, record, is what the key absent means.

Which shape to send

  • event + data is the simpler shape and the right default: one event, one payload, one flow subscribed to your own event type. With a recordKey, or an identity mapped from the payload, it parallelizes like entities once per-record ordering is enabled (see the rollout note above); until then it runs at the per-connector rate.
  • entities batches up to 200 records in one request. event is still required, but flows subscribe to the {type}_synced event type of each entry, not to your event string, and every entry needs a type, its properties and an externalObjectId that is stable for the record it describes. See The entities shape.

The 50-entry advisory above is about the size of one request, not about the shape: many small entities requests are still the real-time path, while a single large batch belongs in bulk import.

How to tell which one you are on

Once per-record ordering is enabled, the 202 hint tells you at ingest time: it mentions the missing record key whenever an event falls back to the per-connector sequence. Before that, no hint is given and every data event is on the per-connector sequence. To confirm it under load, send a burst on one connector and watch the accepted events reach a terminal state via GET /api/v1/events/{event_id}/status or the connector’s Activity tab. Events completing at a roughly constant rate no matter how many are outstanding is the per-connector sequence; events still reporting processing (or delayed) well after they were accepted, with no failures, is the same ceiling showing up as latency rather than as an error. Sending the requests faster, or in parallel, does not lift it. Giving the events a record key does, once per-record ordering is enabled; before that, the entities shape does.

Check processing status

curl https://app.plugsync.com/api/v1/events/{event_id}/status \ -H "x-api-key: ps_live_..."

Returns per-entity progress (entities_total / entities_done / entities_failed / entities_skipped) plus a row per entity with action, status, hubspot_id, and error detail. Each row’s external_id is the record’s key, and is null when the row carries none (a record the flow could not name before it stopped, for example). Earlier versions reported the source name in that case: a null means “no key”, never “this feed”. An event_id that does not exist, or that belongs to another connector than the API key’s, answers 404 with {"detail": "Event not found"}.

The response also carries a boolean terminal: true once the overall status can no longer change (stop polling). It is false while the event is pending, processing or delayed, and also while any entity has not reported a final status yet: a partial_failure with entities still in flight can still change, so keep polling until terminal is true. Poll on terminal rather than on a list of status strings.

terminal is bounded: once at least one entity has reported, it also turns true when the event was accepted more than 48 times the stuck deadline ago (8 hours with the default 600 s deadline), even if other entities never reported. The hint field then says how many entities never reported a final status; check the connector’s sync events for them before sending them again. An event with no entity reported at all stays delayed and never becomes terminal this way. The overall status:

StatusMeaning
pendingThe event was accepted and queued; no entity has reported yet and the stuck deadline has not passed. Keep polling.
processingNot all entities have reached a terminal state yet.
delayedThe event has been queued longer than the stuck deadline (600 s by default) but no entity has been processed at all. No outcome is known yet: it may be waiting behind a backlog or on HubSpot API quota for the portal. Keep polling. The hint field says how long the event has been queued.
completedEvery entity synced successfully, and everything else its flow had to do (a notify delivery, for one) happened too.
skippedEvery entity was a no-op: no published flow matched. Nothing was written.
partial_failureAt least one entity failed, or only part of the event synced (the rest skipped), or an entity synced but part of its flow did not land - a notify step that gave up on its delivery counts here, with the target and status in that entity’s error - or the event is past the stuck deadline with at least one entity already reported.
drainedThe event was discarded by a drain on the connector’s queue: it was never processed and never will be. This status wins over every other one and is always terminal. Resend the event once the drain is off.

delayed and partial_failure both fire off the same stuck-deadline check, but they mean different things: delayed means the event has not started processing at all yet (nothing to worry about beyond queue latency); partial_failure past the deadline means at least one entity did get processed but the rest never reported (worth investigating), and terminal stays false until those report too, or until the bound above has passed. Do not treat delayed as a failure state in your integration’s error handling: like processing, it means the outcome is not known yet.

When the portal’s HubSpot API quota stays exhausted for a long time, plugsync keeps retrying the entity for a bounded window (up to about six hours with the default settings) and then gives up on it: the entity reports status: parked with error_struct.error_code: "quota_exhausted", or status: partial_success with the same error code when part of it had already been written to HubSpot before the quota ran out. The event turns partial_failure; it is terminal once its other entities have reported too, or once the bound above has passed. The entity can be replayed from the dashboard once the portal has quota headroom again. HubSpot API quota alone never keeps an event delayed forever.

Verify a processed event

curl https://app.plugsync.com/api/v1/events/{event_id}/verify \ -H "x-api-key: ps_live_..."

Reads the current HubSpot record back and diffs it against what the event’s own published flow projects for its payload: one entry per entity with status of ok / diff / absent / error / not_synced / not_verifiable and per-property differences. Call /status first: this endpoint does not wait for processing to finish.

Each entry’s external_id follows the same rule as in /status: the record’s key, or null when the row carries none, never the source name.

not_verifiable covers form-only flows (a single submit_form step): the submission was accepted, but HubSpot resolves the contact itself (no record id and no identity mapping), so there is no CRM record to read back and compare.

Bulk import

POST /api/v1/events/bulk loads up to 5000 records per request as one job, written straight to HubSpot without running your flows, for day-0 loads and migrations. The request, the job and row statuses, row replay and the verification of a load against HubSpot are in Bulk import.

  • API error catalog - every error code of the public API, the field that carries it, and the remedy.
  • Bulk import: day-0 loads and migrations, row by row, with verification against HubSpot.
  • Reliability: retry, failures, replay - the retry policy with its real numbers, the terminal event states, and the replay endpoints.
  • Flow authoring: make a flow actually subscribe to your event type.
  • Event sources: event_api is one source type among webhook, HubSpot journal, poller, scheduled, file.
  • Conflicts: how timestamp feeds most_recent arbitration on bidirectional connectors.
  • Getting started, step 8: the same call in tutorial form.
Last updated on