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
| Field | Type | Required | Notes |
|---|---|---|---|
event | string | yes | The 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. |
data | object | yes* | 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. |
timestamp | ISO 8601 datetime | no | The 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. |
storeId | string | no | Multi-brand routing. Can also be sent as the x-store-id header; falls back to the connector’s default store. |
recordKey | string (max 256) | no | The 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. |
entities | array | no* | 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:
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | The 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"). |
externalObjectId | string | yes | Your stable id for the record. Inside flow manifests it is $.external_id, next to the payload and not inside it. |
properties | object | yes | The 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 request | In 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, storeId | Shared 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.idfinds it only if you also sentidinsideproperties. - With
event+datathere is no$.external_id: the id is whatever field ofdataholds 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 answers429and the event has to be sent again. - Payload too large for the real-time path: an
entitiespayload above 50 entries gets pointed at bulk import. - No record key: once per-record ordering is enabled, a
dataevent for which no record key could be found (norecordKey, 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 withordering: "connector".
Errors
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, malformed, or revoked API key. | Check the x-api-key header; mint a new key if revoked. |
422 | Body validation failed (e.g. a stray payload key, missing event). | The detail message states the exact field. |
423 | Ingestion 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. |
429 | Rate limited. Three distinct causes share this status. See 429 below. | See below. |
503 | A 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:
| Tier | Requests per second | Events per month |
|---|---|---|
| Free | 10 | 2,000 |
| Trial | 100 | 20,000 |
| Starter | 25 | 25,000 |
| Pro | 50 | 75,000 |
| Scale | 100 | 400,000 |
| Enterprise | 100 | unlimited |
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: adata: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
dataonPOST /api/v1/events. A base64 string inside anentitiesentry 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_fieldis 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.
-
filenameis always{numdoc}_{version}.pdf, wherenumdocandversioncome from thefile_numdoc_field/file_version_fieldsettings (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.pdfwhatever the file is. When a part does not resolve it is empty (9000_.pdf, or_.pdfwith 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 ownnumdoc/versionpair. -
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 tonull, and the deal syncs without the file. -
The file cannot be stored: the call answers
503and 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
entitiesshape (byexternalObjectId, one sequence per record, with no bound) and the rows of an ingested file (by row identity). In the meantimerecordKey, a source’sid_jsonpath, theorderingsetting andrecord_bucketsare 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,entitiesis the shape that parallelizes per record.When per-record ordering is enabled,
entitiesfollows the same bound as every other shape: its records are spread over the connector’srecord_bucketssequences instead of one sequence each. A connector that relies onentitiesfor throughput then drains at mostrecord_bucketsevents per second; raiserecord_bucketsif the target system absorbs more.
entities: each entry’s externalObjectId is its record key.
event + data: the key is, in this order,
- the
recordKeyyou send in the body; - 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 indatais the key. A contact flow whoseemailidentity is mapped from$.payload.emailis 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
recordKeyon 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+datais the simpler shape and the right default: one event, one payload, one flow subscribed to your own event type. With arecordKey, or an identity mapped from the payload, it parallelizes likeentitiesonce per-record ordering is enabled (see the rollout note above); until then it runs at the per-connector rate.entitiesbatches up to 200 records in one request.eventis still required, but flows subscribe to the{type}_syncedevent type of each entry, not to youreventstring, and every entry needs atype, itspropertiesand anexternalObjectIdthat is stable for the record it describes. See Theentitiesshape.
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:
| Status | Meaning |
|---|---|
pending | The event was accepted and queued; no entity has reported yet and the stuck deadline has not passed. Keep polling. |
processing | Not all entities have reached a terminal state yet. |
delayed | The 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. |
completed | Every entity synced successfully, and everything else its flow had to do (a notify delivery, for one) happened too. |
skipped | Every entity was a no-op: no published flow matched. Nothing was written. |
partial_failure | At 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. |
drained | The 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.
Related reference
- 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_apiis one source type among webhook, HubSpot journal, poller, scheduled, file. - Conflicts: how
timestampfeedsmost_recentarbitration on bidirectional connectors. - Getting started, step 8: the same call in tutorial form.