Reliability: retry, failures, replay
What happens to an event after it is accepted, how many times the platform retries it, what each terminal state means, and how to replay the ones that did not land. The platform is designed so that a record that fails after it was accepted ends up as a row you can see, filter and, while its payload is kept, replay. The endpoints behind the dashboard’s connector overview, which you can call from your own monitoring, are in Monitoring a connector.
Some events never become such a row, or cannot be replayed:
- an event rejected at ingress (a
4xxsuch as429) was never accepted: nothing is queued and it has to be sent again (see Event API status codes); - an event discarded by a drain is gone;
- an event still unprocessed after 14 days in the queue expires;
- a row without a captured payload is visible but not replayable.
The numbers below describe how the platform behaves today. They may change as the platform evolves and are not service levels; timings in particular are typical values, not bounds.
The delivery path
An accepted event is queued on the side it belongs to (one lane for events
entering HubSpot, one for events leaving it) and is processed in order: per
record when the event carries a record key the platform orders by, per
connector otherwise. Per-record ordering is being rolled out progressively:
until it is enabled for your environment, only the Event API entities
shape and file rows are ordered per record, and every other event keeps the
per-connector order (see
processing order and throughput).
The platform then picks it up, runs every matching flow, and either completes
it, leaves it for another attempt, or writes a terminal record and stops.
| Property | Value |
|---|---|
| Delivery attempts per event | 5 |
| Default wait between attempts | 10 s, doubling each time: 10, 20, 40, 80 s (ceiling 160 s) |
| Default retry horizon | roughly 2.5 minutes: four gaps totalling 150 s between the five attempts, plus processing time |
| How long an unprocessed event survives | 14 days |
Only the attempt count is fixed. The wait between attempts is
configurable, and the platform lengthens it on its own when a target answers
429 with a Retry-After. One case does not wait in place at all: an
association whose counterpart has not synced yet steps aside and lets the
connector’s other events past. Both are described below.
Which failures are retried
The engine classifies every failure into one of three classes, and the class decides whether a retry can possibly help:
| Class | Typical cause | Behavior |
|---|---|---|
| Transient | Target returned 5xx or 429; a plugin signalled “retry”; a related record has not synced yet | Retried, up to the 5-attempt budget. On exhaustion the record becomes parked (or failed, see below). |
| Permanent | Any other 4xx from the target: validation error, unknown object, malformed request | Not retried. Retrying cannot fix it, so the record goes terminal (failed) immediately. |
| Fatal | 401 / 403, revoked or dead credential | The event is held rather than burned against a broken credential, and the flow’s circuit breaker starts counting. |
A fatal outcome is rarely connector-wide (a wrong form GUID or a missing OAuth scope breaks one flow while its siblings keep working), so the breaker is per flow, and a fatal streak is only one of three independent ways into it. See Circuit breaker below.
Circuit breaker
Three independent conditions open a flow’s circuit breaker, each scoped to
one (connector, flow) pair so a fault in one flow never blocks its
siblings:
| Trigger | Condition |
|---|---|
| Fatal streak | 3 consecutive fatal outcomes (see the table above). |
| Exhausted-retry streak | 5 consecutive records for the same flow that end their retry budget without that flow writing anything (typically parked, see terminal states below). A record whose main write landed and whose accessory step alone ran out of attempts does not count towards this streak: the target is reachable, so stopping the writes that work would not repair the step that does not. |
| Failure rate | More than half of a flow’s executions ended in a fatal failure (the class above), over at least 20 executions in the last two hours (checked every 5 minutes) - the sample floor keeps a quiet flow with one bad run from tripping on noise alone. See what counts as a failure for the rate. |
“Consecutive” means with no successful write of that flow in between: only
an execution that actually wrote restarts both streaks. A skipped execution,
or one that completed without writing anything (for example because every
writing step was gated off by its when), leaves them where they are. A flow
that fails often but writes in between is caught by the failure rate instead.
The count survives restarts and does not depend on how much traffic the flow
receives, so the thresholds above hold as stated.
While a flow’s breaker is open:
- That flow is skipped. Events it had already accepted wait rather than
being lost, and are processed once the flow recovers. New events for the
affected event type can be refused at ingress with
429(see the next point): those were never accepted and have to be sent again after theRetry-Afterdelay. Every other flow on the connector keeps syncing normally, andPOST /api/v1/eventskeeps accepting for every other event type - even if every enabled flow on the connector happens to be tripped at once. That case never suspends the connector itself: each affected event type still answers on its own terms (see429below), and the automatic recovery attempt for every flow keeps running on its own schedule. POST /api/v1/eventsfor the affected event type can answer429instead of202- see the 429 section of the Event API reference for the exact body and header.- An event that matches more than one flow still runs its healthy flows right away; only the part routed through the open flow waits. That wait is paced to the breaker’s own recovery schedule (below) rather than the standard delivery attempts, so a long-open flow does not repeatedly re-send what already succeeded elsewhere in the same event - though it does eventually re-apply it once the flow’s own recovery attempt runs, which is harmless: see What a retry re-executes below.
What counts as a failure for the rate
Only the fatal class from the table above: an execution that failed on the credential itself - authentication or authorization against the target. That is what this trigger is for, a flow that cannot reach its target at all.
A validation error from the target - the permanent class: a bad mapping,
a missing required property, a value the target rejects - does not count
towards the rate, however often it happens. The record still goes terminal
failed, with its error, visible in the dashboard and replayable once the
mapping is fixed; what does not happen is the flow’s breaker opening, so
events keep flowing and keep failing one by one until you fix them. Watch
the failed records for that class, not the breaker.
The remaining outcomes either count as successes or stay out of the ratio entirely:
- An execution whose main write landed and whose later, accessory step did
not counts as a success - the common shape is an association pointing
at a record that has not been synced yet, which ends the record
partial_success. The data made it, so a side step that has to wait must never park the writes that are landing. - A failure that is still being retried is out of the ratio. Retries that genuinely give up feed the exhausted-retry streak instead, one row above.
- A notification (a
notifystep) that could not be delivered is out too. A notification target’s outage never fails the flow whose writes are landing. - A plugin that declares a failed outcome
does not count either. Its target answered and refused the record: the
record ends
failedand replayable, but the flow itself is healthy, so it counts toward neither the rate nor either streak. - An execution that ended skipped counts as a success: a skip is not a
failure. It leaves the fatal and exhausted-retry streaks where they are,
because only a successful write restarts them.
This includes an inbound write dropped as the echo of this connector’s own
recent write, which runs no write at all. That skip happens on the inbound
flow about to write to HubSpot, through
freshness_check(on by default), whenever the values match a recent fingerprint of the connector;suppress_echoonly decides which writes get fingerprinted, so a connector without it can still skip echoes (see Echo suppression). On such a flow a burst of echoes dilutes its failure rate, so discount them before reading the flow’s health: the sync-events summary withinclude=skips_by_flowcounts them per flow.
The other two triggers are unaffected by any of this: they count what one execution did, as it happens, not a ratio over a window.
Automatic recovery
The wait before the next recovery attempt starts at 5 minutes and grows (15, then 45 minutes, up to a 6-hour cap) each time an attempt fails, and resets back down after the flow stays closed for a full hour. Once the wait elapses, the next event through that flow is the recovery attempt: if it succeeds the breaker closes for good; if it fails the wait starts climbing again. Nothing needs to be done manually if the underlying problem (a down target, a bad mapping) clears on its own - the flow finds out and recovers by itself.
Once a flow’s breaker closes - on a recovery attempt, or because you set the
connector back to active - the failure rate no longer looks at the hours
that led to the trip: it ignores everything up to and including the hour the
breaker closed in. Re-opening a recovered flow takes new failures, not the
ones the recovery already answered for, so a flow does not bounce between
open and closed for the rest of the two-hour window. A flow that starts
failing again is picked up from the next hour onward, and both streak
triggers keep watching it live in the meantime.
Checking and retrying from the dashboard
GET /api/connectors/{id} reports a health block:
{
"state": "degraded",
"flows": [
{
"name": "order_sync",
"state": "open",
"reason": "transient_exhausted_streak",
"detail": "upsert order: the target API returned 503 on every attempt",
"opened_at": "2026-09-05T14:02:11Z",
"retry_after_at": "2026-09-05T14:17:11Z",
"cooldown_seconds": 900,
"opens": 2
}
]
}state rolls every flow up into healthy (nothing tripped), degraded (at
least one flow open or half_open), or error - either because the
connector’s own status is error for a reason unrelated to any breaker, or
because every enabled flow is open and still inside its wait at once (a
flow that is half_open, or open with its wait already elapsed, is ready
for its recovery attempt and keeps the rollup at degraded). That second case
is a rollup computed when you ask, not a change to the connector: nothing
else about it is different, and every flow keeps recovering on its own
schedule regardless. opens counts how many times in a row this flow has
tripped without a full healthy hour in between - it is what makes the wait
keep climbing on a flow that will not stay fixed.
state travels on the connector list too: GET /api/connectors carries it
per connector (without the per-flow detail), and the dashboard shows it as a
badge on each row of the connectors list, with a filter to see only the
connectors in one state.
The dashboard shows the same thing as a banner on the connector, with a
“Retry now” action per flow (POST /api/connectors/{id}/flows/{flow_name}/retry,
also reachable at the older .../breaker/reset path): it triggers the
recovery attempt immediately instead of waiting out the timer. Calling it on
a flow already mid-attempt (half_open) is a no-op; calling it on a flow
that is not tripped at all answers 404 - there is nothing to retry.
Setting the connector back to active (PATCH /api/connectors/{id}) still
closes every flow’s breaker at once, unconditionally.
The CLI does the same two things with the org API key, for scripts and
pipelines: plugsync connector breakers <connector> lists the flows whose
breaker is not closed, and plugsync connector flow retry <connector> <flow>
triggers the recovery attempt on one of them. See
Retrying a flow whose circuit breaker is open.
Backoff
Default: short exponential
Out of the box a retried event waits 10 s, then 20 s, 40 s and 80 s before its second to fifth attempt, with a 160 s ceiling on any single wait. The whole budget is spent in about two and a half minutes. That is the right shape for a target that is briefly unavailable and wrong for a dependency that takes minutes to arrive, which is why a connector can choose a longer scale.
Choosing your own backoff
Set the retry block inside the connector config’s settings object, then
publish:
{
"version": "3.0",
"entities": [],
"flows": [],
"settings": {
"retry": {
"backoff_base_seconds": 60,
"backoff_max_seconds": 600
}
}
}| Key | Default | Meaning |
|---|---|---|
backoff_base_seconds | 10 | First retry delay in seconds. Each subsequent attempt doubles it. A value of 0 turns the backoff off: the event is then retried after up to about 2 minutes, depending on how long the failed attempt took to process. |
backoff_max_seconds | 160 when backoff_base_seconds is not set, 600 when it is | Cap on the computed delay. Hard-clamped to 12 h (43200 s), the platform maximum. |
With backoff_base_seconds: 60 the waits are 60 s, 120 s, 240 s, 480 s,
capped by backoff_max_seconds, which stretches the same five attempts over
roughly fifteen minutes instead of two and a half.
The attempt count is never widened by this block: settings.retry buys a
longer wait, not more tries. The 5-attempt ceiling is platform-wide and a
connector cannot exceed it.
settings is a free-form object with no sub-schema, so a mistyped value
("60s", a list, an object) does not break the connector: the platform
falls back to the default for that key and keeps processing.
Retry-After on 429
When an outbound target answers 429, plugsync treats it as transient (rate
limiting is a slowdown, not a broken event) and honors the response’s
Retry-After header. Both RFC 7231 forms are accepted, delta-seconds
(Retry-After: 120) and HTTP-date, and the parsed value is clamped to
12 hours (43200 s), the platform ceiling. A malformed header is ignored
and the event falls back to the standard wait.
Retry-After is honored even without a settings.retry block, and even
with the backoff turned off. When both apply, the wait is max(exponential backoff, Retry-After): never
sooner than the target asked, never shorter than an already-longer backoff.
Associations waiting on a counterpart
An associate_many step whose counterpart record has not been synced yet is
not a broken event: the counterpart can still arrive on a later event, so the
record keeps its retries. What is different about this case is where it
waits.
Events are processed in order, so an event that waits in place holds up the ones queued behind it. A record waiting on a counterpart therefore does not wait in place: it is moved to the back of its connector’s queue and tries again when its turn comes round, while every other record of the connector keeps flowing at full speed. The trade is ordering, and it applies to that one record only: it is no longer processed in its original position relative to the events queued beside it. Nothing else about it changes - same five attempts, same event, same flows.
How long each wait lasts is whatever the connector’s own traffic makes
it. There is no fixed per-attempt delay for this case: the record comes
back as soon as it reaches the front of the queue again. On a connector with
a deep backlog that is a long wait, and the counterpart usually arrives
inside it. On a connector whose queue is otherwise empty it can be a few
seconds, so all five attempts can be spent almost immediately - the record
then ends parked and is replayed once the dependency exists, rather than
waiting minutes in the hope it shows up.
If the counterpart never appears within the five attempts, the record ends
parked and replayable, carrying a distinct
association_endpoint_unresolved error code rather than a generic transient
failure, so “the other side never arrived” is not confused with “the target
was flaky”. Replay it once the counterpart exists (and if a step of the same
flow had already written to HubSpot, the row is partial_success instead -
see the terminal states).
Two ways to avoid the round trip altogether, both worth preferring when the order of arrival is under your control: send the counterpart first, or send both records in the same event so one flow creates them together.
Plugins waiting on an earlier event
A plugin can ask for the same treatment: a retry response with
requeue: true sends the record to the back of its connector’s queue
instead of retrying in place. It gets up to 10 laps of at least 30 seconds
each, writes nothing to sync-events between laps, and once the laps are
spent ends parked (or partial_success) with the error code
plugin_requeue_exhausted. The contract, its rules and what it costs in
ordering are in
Waiting for an earlier event.
What a retry re-executes
A retry is per event, not per step. On every new attempt the flow runs from its first step again, in full - there is no checkpoint that lets a later attempt resume from the step that failed and skip the ones before it. Every one of the (up to) five delivery attempts runs the exact same sequence of steps, from the top, including whichever ones already succeeded on an earlier attempt.
That is safe by design for the steps the platform controls directly:
| Step | Why re-running it is safe |
|---|---|
upsert (target hubspot) | Identity mapping resolves the same source record to the same HubSpot record every time. A repeated write updates the record it already wrote; it does not create a second one. Two exceptions: HubSpot offers no conditional write, so two attempts for a record that has no mapping yet, running at the same instant, can in rare cases each create one (events for the same record are normally processed one after the other, which keeps this window narrow); and after the connector is moved to a different HubSpot portal the mappings are cleared, so a record may be created again (see the first events after the change). |
associate / associate_many | The link is written once per pair; asking to link a pair that is already linked is a no-op. |
| Writes the platform itself produced (opt-in) | Only when the flow asks for it. A write into HubSpot comes back as a change leaving HubSpot that an outbound flow would otherwise push to the source again: a flow that sets suppress_echo (default false) fingerprints its HubSpot writes, so outbound events of the same connector whose changed properties match a recent fingerprint are dropped. Recognition is best-effort and fails open, so a repeated attempt can still see its own write come back; freshness_check (on by default) is what stops that echo from rewriting HubSpot. See Echo suppression. |
Two kinds of step are not idempotent by construction, because nothing in the platform can make an external side effect repeat-safe on your behalf:
- A
poststep without an identity-aware round trip is a plain HTTP call on every attempt. If the target already accepted the first attempt (created a record, charged something) before that attempt could be acknowledged, a retried attempt sends the exact same request again. Design for this by giving the target an idempotency key, or by configuring the entity’s schema with anidentityblock so the step switches to an identity-aware create-vs-update round trip: a repeated attempt then finds the existing mapping and updates instead of creating again. - A
pluginstep with an external side effect (sending an email, calling a paid API, writing to a system with no create-or-update semantics of its own) runs that side effect again on every retried attempt that reaches it, exactly like any other step. Design the plugin to check first: keep a marker of “already done” behind a stable key derived from the payload, and skip the side effect if the marker is already there. The plugin key/valuestorecapability exists for exactly this (worked example: Generate-doc walkthrough).
This is also why a flow can land as partial_success: an earlier step
already wrote successfully and is safe to repeat, while a later step in the
same flow did not complete. See the table below.
Event lifecycle: the terminal states
One sync-events row is written per record, per terminal outcome (the rows
GET /api/connectors/{connector_id}/sync-events returns). The full set of
statuses:
| Status | Meaning | Replayable |
|---|---|---|
success | Every step of every matching flow completed. | No, nothing to retry |
skipped | No published flow matched, or the write was suppressed (for example an echo caught by loop prevention). Nothing was written and nothing is wrong. When a flow ran and stopped itself, error_struct.context.skip_kind says why (see Why a record was skipped). | No, a replay would no-op again |
failed | A permanent error, or a mix of failures where at least one is genuinely broken. Also a record whose plugin declared a failed outcome, with the plugin’s reason as the error. Needs a fix. | Yes |
partial_success | Part of the event landed and part did not. Either an earlier step wrote to HubSpot (the upsert created the record) and a later step in the same flow failed, typically associate_many; or every write succeeded and a notify step gave up on its delivery, in which case the error detail names the notification target and the status it answered with. See What a retry re-executes above. | Yes |
parked | A pure transient failure that exhausted its retry budget without ever writing anything, an association whose counterpart never arrived included. Not a bug: the dependency simply outlasted the retry window. | Yes |
quarantined | Deliberately not applied, for one of two reasons named by error_struct.error_code. identity_guard_quarantined: too many of the record’s declared identity fields changed at once on an existing record, which usually means a source-side join error reassigning identity. target_deleted_quarantined: the HubSpot record this external id was linked to no longer exists (deleted, archived or merged away between syncs) and no other record carries the same identity value; the detail names the missing record id. Later events for the same external id are skipped until the record is replayed with force=true or its mapping is repaired. The event’s error_detail states the reason in words: the fields that changed and the threshold for the first, the missing record id and the on_target_deleted setting for the second. A quarantine that names neither reason carries quarantined_unspecified, or its own code, and a generic message that quotes it. | Yes, with force=true |
dlq | The event never reached a classified outcome at all. Platform-managed, see The dlq status. | Not from this surface |
Two distinctions are worth internalizing, because they are the ones that decide what you do next:
parkedversusfailed.parkedmeans “give it more time and replay it”;failedmeans “this needs a fix”. A message whose flows produced a mix of outcomes (one flow waiting on a dependency, another genuinely broken) is reported asfailed, neverparked: a replay alone could not fix it, so the state does not promise that it could.partial_successversusparked. If any step already wrote to HubSpot, the row ispartial_successeven when the failure was transient. The write-completeness signal wins, because hiding a landed write would be worse than under-reporting the failure class.
There is no automatic re-drive once a dependency clears. A parked record
stays parked until you replay it.
What each status means for the target
The status is a statement about what is on the target, and you can build on it. It describes the record, across every delivery attempt it took, not only the last one; the error on the row is the one of the last attempt.
| Status | What it means |
|---|---|
success | Every matching flow completed. Once a record is success, a later attempt of the same event never turns its row back into a failure. |
partial_success | At least one attempt of this record wrote to the target, and target_id names the record it wrote. This holds when the write landed on an earlier attempt and the last attempt failed before reaching its own write: the row keeps the earlier write’s target_id and shows the last attempt’s error. |
failed, parked | No attempt of this record reported a write to the target. target_id is empty. One exception: a record whose plugin declared a failed outcome is failed even when a later step of the flow wrote (typically the error written back to the source record), and target_id then names the record that step wrote. The status says the record did not sync, not that nothing was written. |
skipped | This event wrote nothing, on any attempt. A record where one flow wrote and another stopped itself is a success, not a skipped. So is a record whose earlier attempt wrote before a later attempt ran no flow (a publish disabled the flow in between): the row keeps the earlier write’s target_id. |
The same rules hold whether a record was processed on its own or together with others in a batch, and whichever way an attempt came back: a retry after a transient error, a wait for an association counterpart, a flow recovering from an open breaker, or a pause for the HubSpot API quota.
One limit: a write the platform never learned about does not count. The
common case is a write the target applied whose response never came back (a
timeout in the middle of the request). A
failed or parked row can therefore sit next to a record the target does
hold. Replaying it is safe for upsert and associate steps, which resolve
to the record already written (see
What a retry re-executes).
What success guarantees, and what it does not
success is a statement about the steps of your flows, not about the systems
on either side of them.
It guarantees that:
- every flow that matched the event ran to its end without a step failing;
- every step that ran had its request accepted by the system it addressed.
For a HubSpot write that means HubSpot accepted the record. For a
poststep it means the target answered with a2xx; - something was written. An event that wrote nothing is
skipped, notsuccess(see the table above).
It does not guarantee that:
- A REST target applied the change. A
poststep reads the status code and nothing else: a200whose body says{"ok": false}, or a202the target later drops, is a delivery like any other. See Response: any2xxis a delivery. - The target still holds what was written. The status is recorded when
the event is processed. A user or another integration can change or delete
the record a minute later and the row stays
success. - The source data was right. The event is synced as it was sent. A
successsays nothing about the state of the system that produced it, nor about whether the payload was complete or current there. - Every step of every flow wrote. A step whose
whencondition is false does not run, and a flow that stopped itself next to one that wrote still leaves the recordsuccess(see the table above). The steps that ran are in the record’sstep_trace.
When you need more than that:
- To check what is on HubSpot now, call
GET /api/v1/events/{event_id}/verify, which reads the record back and compares it with what the flow projects. - To make a business rejection inside a
2xxbody count as a failure, call the target from a plugin and declare a failed outcome when the body says so.
Why a record was skipped
A skipped row written after a flow ran carries the reason in
error_struct.context.skip_kind. A flow that stopped itself also records it
on the last entry of that flow’s part of the step_trace, which is where it stays when another
flow of the same event wrote and the row is a success. The values:
skip_kind | Meaning |
|---|---|
echo_suppressed | The inbound write only repeated values this connector itself wrote to the record a moment ago: the echo of its own write, dropped. The event did arrive. |
stale_source | The event was older than what the target already holds (reject_stale_source). |
outbound_superseded | The value to send out had already been replaced on HubSpot. |
changeset_empty | After narrowing to the changed properties, nothing was left to send. |
deleted_404 | The target record no longer exists. |
target_deleted | The record is known to be deleted on the target. Every later event for the same external id is skipped the same way until the mapping is repaired: see Repairing one identity mapping. |
identity_superseded | The record’s external id was moved to another record. |
invalid_email | HubSpot rejected the email value as malformed. |
update_record_not_found | An update step found no record to update. It never creates one. |
create_record_exists | A create step found the record already there. It never modifies one. The rest of the flow still ran; the event carries this kind only when no step wrote anything. |
explicit_skip_step | A skip step stopped the flow. |
plugin_skip | A plugin answered with a skip. |
when_false | Every step was switched off by its when condition. |
missing_ref | A step referenced an output that was never produced: usually a broken manifest. |
no_write_step | The flow ran every step and none of them wrote: it declares no writing step, or its only one was an update_by_id that mapped_properties_only left with nothing to send. |
unspecified | A skip without a recorded reason. |
Filter by it with GET .../sync-events?skip_kind=echo_suppressed&since=...,
and count it per flow with include=skips_by_flow on the summary (see below). A record that
never arrived has no row at all; an echo-suppressed record always has one.
In the dashboard, the connector’s Activity tab (Events sub-tab) filters the list by skip reason and shows, for each flow, how many of its executions in the last 7 days stopped themselves and why.
Repairing one identity mapping
plugsync remembers, per connector, which HubSpot record each external id was
written to: the identity mapping. Two situations leave a mapping that keeps
producing the wrong outcome on every later event, and neither is fixed by a
replay, because a skipped event is not replayable:
- The mapping is marked as deleted on the target. The HubSpot record was
found deleted, the event was
quarantinedwithtarget_deleted_quarantined, and from then on every event for that external id isskippedwithskip_kind: target_deleted. - The mapping points at the wrong record, for example a stub created by an early test, so every event updates a record nobody looks at.
Two endpoints repair exactly one mapping, addressed by its full key, and a
third reads it first. All three are for an organization owner or an
organization API key; a member gets 403. A connector of another organization answers 404, like an id
that does not exist.
| Request | What it does | What the next event does |
|---|---|---|
GET /api/connectors/{connector_id}/identity-mappings/record/mapping | Reads the stored mapping without calling HubSpot: hubspot_id, hubspot_object_type, tombstone (the marker, or null), is_prewarm_alias and other_mappings_on_record. Changes nothing. | Nothing changes. |
DELETE /api/connectors/{connector_id}/identity-mappings/record | Removes the mapping. | Resolves the record from scratch with the entity’s identity rule: updates the record it finds, or creates one. |
DELETE /api/connectors/{connector_id}/identity-mappings/record/tombstone | Removes only the deleted-on-target marker and keeps the mapping. | Is written to the same HubSpot record again. |
Use the first when the record is gone for good or was the wrong one; use the second after restoring the record on HubSpot. Neither touches the HubSpot record itself, and neither re-processes past events: the repair takes effect on the next event for that external id.
How the next event arrives depends on the source:
- Events you send, and feeds that serve each change once: send the event again from the source system, or wait for the next one.
- File sources: a row that is unchanged in the next extract is filtered
before it becomes an event, so waiting for the next file does nothing for
that record. It needs a row whose content changed, or an explicit replay
of the record’s
quarantinedrow withforce=true(see Replaying a failed record). - Polled feeds with
dedupon behave the same way for an item the feed keeps serving unchanged.
Removing a mapping can create a duplicate. After the removal the next
event looks the record up with the entity’s identity rule. If the HubSpot
record still exists and the rule does not find it (the identity property is
empty or holds another value on it), a second record is created. Read the
mapping first, note its hubspot_id, and check that record on HubSpot
before removing.
Query parameters, the same on both:
| Parameter | Required | Meaning |
|---|---|---|
entity_type | yes | The entity name the mapping belongs to. |
external_id | yes | The external id, matched exactly. It is never a pattern: * is an ordinary character. |
store_id | no | The store id, for a mapping written under a store. Without it the mapping with no store is addressed. |
reason | no | Free text, up to 500 characters, recorded with the removal. |
# The record was deleted on HubSpot and must be created again
curl -s -X DELETE "$BASE/api/connectors/$CONNECTOR_ID/identity-mappings/record?entity_type=deal&external_id=A1&reason=recreate" \
-H "Authorization: Bearer $PLUGSYNC_API_KEY"{
"removed": "mapping",
"connector_id": "3f7c1e9a-...",
"entity_type": "deal",
"external_id": "A1",
"store_id": null,
"hubspot_id": "9",
"hubspot_object_type": "deals",
"had_tombstone": true,
"other_mappings_on_record": []
}hubspot_id is the record the mapping pointed at (null for a mapping
stored without one). other_mappings_on_record lists the other mappings
that still point at the same HubSpot record after the removal (another
external id, or the same one under another entity or store). They are
reported, not removed: each call removes one mapping. The tombstone endpoint answers with "removed": "tombstone"
and the marker it removed under tombstone (at, hubspot_id).
There is no request that removes more than one mapping, and none that removes every mapping of a connector.
Mappings written by a pre-warm
A record mapped by an identity map pre-warm can hold
more than one mapping: the one the pre-warm wrote, and one more for each
other spelling of the external id (a different letter case) that an event
has arrived with since. They all point at the same HubSpot record and show
up in other_mappings_on_record; the read endpoint marks the extra
spellings with is_prewarm_alias: true. Removing one of them does not
remove the others:
- Removing only an extra spelling has no lasting effect. The next event with that spelling resolves through the mapping the pre-warm wrote and the removed mapping is written again, pointing at the same record.
- Removing only the mapping the pre-warm wrote leaves the extra spellings in place. Events that arrive with one of those spellings keep being written to the old record.
To detach such a record completely, remove every mapping listed, one call
each, starting from the one the pre-warm wrote (the one with
is_prewarm_alias: false).
| Status | detail.error | Meaning |
|---|---|---|
404 | mapping_not_found | No mapping at that key. Not treated as success: check the entity name, the exact external id and the store id. |
404 | tombstone_not_found | The mapping exists and is not marked as deleted (tombstone endpoint only). |
404 | none (detail is Connector not found) | Unknown connector, or one that belongs to another organization. |
403 | owner_required | The caller is a member, not an owner or an organization API key. |
409 | mapping_changed | An event changed the mapping while the request was running. Nothing was removed; read the mapping again and retry. |
422 | invalid_mapping_key | A key that cannot exist: an empty value, a # in it, longer than 256 bytes, or store_id=_ (reserved: leave store_id out for a mapping without a store). A missing required parameter also answers 422, with the standard validation body. |
502 | identity_store_unavailable | The mapping could not be read, or the write did not complete. After a failed write the outcome is unknown: it may or may not have been applied. Read the mapping back before retrying. |
Every removal request is recorded for your organization with who made it
(the user, or the label of the API key), the connector, the entity, the
external id, the HubSpot record id, the reason and how it ended: removed,
refused because the mapping changed (409), or failed (502). The record
is deleted with the connector and with the organization. Do not put
personal data in reason.
The same two operations are available from the CLI as
plugsync connector identity.
Running a pre-warm through the API
The identity map pre-warm
the CLI drives is a set of REST endpoints, so a cutover script can run it
without the CLI. What a pre-warm reads, what it writes and what it leaves
alone is described there; this section covers the requests. Every endpoint
needs the organization API key or an owner’s login: a member who is not an
owner gets 403 with {"detail": {"error": "owner_required"}}.
| Endpoint | Verb | Purpose |
|---|---|---|
/api/connectors/{connector_id}/identity-prewarm | POST | Start a job. 202 with the job. |
/api/connectors/{connector_id}/identity-prewarm | GET | The connector’s 20 most recent jobs, newest first: {"jobs": [...]}. |
/api/connectors/{connector_id}/identity-prewarm/{job_id} | GET | One job and its progress. |
/api/connectors/{connector_id}/identity-prewarm/{job_id}/findings | GET | The records the job did not map, and why. |
/api/connectors/{connector_id}/identity-prewarm/{job_id}/resume | POST | Continue a failed or interrupted job from its last saved page. 202 with the job. |
/api/connectors/{connector_id}/identity-prewarm/{job_id}/cancel | POST | Cancel a job for good. 200 with the job. |
Every field of the start body is optional; an empty body ({}) starts a
full pass over every eligible entity:
curl -X POST https://app.plugsync.com/api/connectors/{connector_id}/identity-prewarm \
-H "Authorization: Bearer <organization API key>" \
-H "Content-Type: application/json" \
-d '{"dry_run": true, "entities": ["contact"]}'| Field | Default | Meaning |
|---|---|---|
dry_run | false | Count what would be mapped per entity and write nothing. |
modified_after | none | Incremental pass: map only records modified on HubSpot after this moment. ISO 8601 with a timezone ("2026-10-01T08:00:00Z"); without one the request is refused with 422. Every record is still read, so ambiguity is judged against the whole portal. |
store_id | none | Only for records that will arrive through bulk import with that storeId; leave it out for event flows. At most 255 characters and 256 bytes in UTF-8, with no #; otherwise the request is refused with 422. |
entities | every eligible entity | Limit the run to these entities of the published config. |
The job, as every endpoint above returns it:
{
"job_id": "5d1e...",
"connector_id": "3f2c...",
"status": "running",
"dry_run": true,
"modified_after": null,
"store_id": null,
"origin": "org",
"requested_by": "api-key:cutover-script",
"resumable": false,
"entities": [
{
"entity": "contact",
"object_type": "contacts",
"property": "external_id",
"phase": "scan",
"pages": 12,
"scanned": 1200,
"missing_value": 30,
"invalid_value": 0,
"not_modified": 0,
"mapped": 1150,
"already_present": 20,
"conflicting": 0,
"mapped_elsewhere": 0,
"ambiguous_values": 0,
"ambiguous_records": 0,
"error": null
}
],
"last_error": null,
"created_at": "2026-10-14T07:00:00+00:00",
"started_at": "2026-10-14T07:00:00+00:00",
"heartbeat_at": "2026-10-14T07:00:41+00:00",
"completed_at": null
}statusis one of the statuses listed in the CLI reference:running, thencompleted,partial_failure,failed,cancelled,supersededorexpired(queuedmay also appear before the job starts). PollGET .../identity-prewarm/{job_id}until it leavesrunning.resumabletells you whether the job’s state allows aresume: it isqueued,failed, orrunningwithout progress for more than five minutes. Only the connector’s latest job can be resumed, so an older job can readresumable: trueand still be refused withjob_not_resumable.originis"org"for a job started from your organization and"admin"for one started by plugsync support;requested_byis the user’s email orapi-key:<label>.- Per entity,
phaseisscan,apply,doneorfailed, anderrorsays why an entity failed. The counters are the ones the CLI prints:scannedis Scanned,mappedis Mapped,already_presentis Present,conflictingis Conflicts,ambiguous_valuesis Ambiguous (held byambiguous_recordsrecords),mapped_elsewherecounts the records already mapped under another external id, and Skipped ismissing_value+invalid_value+not_modified.
GET .../findings pages through the records that were not mapped, ordered
by entity, value and HubSpot id:
| Query parameter | Default | Meaning |
|---|---|---|
finding | all | ambiguous, conflict or mapped_elsewhere. |
limit | 200 | From 1 to 1000. |
offset | 0 | Rows to skip. |
{
"job_id": "5d1e...",
"total": 1,
"limit": 200,
"offset": 0,
"findings": [
{
"entity": "contact",
"finding": "mapped_elsewhere",
"value": "CUST-1042",
"hubspot_id": "51234",
"existing_hubspot_id": null,
"existing_external_id": "cust-1042"
}
]
}existing_hubspot_id is the record a conflict value is already mapped to;
existing_external_id is the external id a mapped_elsewhere record is
already mapped under. Starting a new pre-warm discards the findings of the
earlier jobs, so read them first.
Errors carry detail.error and a detail.message that says what to do
(also in the error catalog):
| Status | detail.error | Meaning |
|---|---|---|
404 | connector_not_found | Unknown connector, or one that belongs to another organization. |
404 | job_not_found | No job with that id on this connector. |
403 | owner_required | The caller is a member, not an owner or an organization API key. |
409 | no_active_config | The connector has no published version yet. The pre-warm maps the entities of the published config: publish first. |
409 | prewarm_already_running | One pre-warm runs per connector at a time. Wait for it, or cancel it. |
409 | job_not_resumable | Only the connector’s latest job can be resumed, and only when it is queued, failed, or still running without progress for more than five minutes. A completed, cancelled, superseded or expired job never can: start a new one. |
409 | job_not_cancellable | The job already ended as completed, partial_failure, superseded or expired; detail.status says which. Cancelling a job that is already cancelled answers 200. |
422 | entity_not_eligible | An entity in entities is not in the published config or has no HubSpot schema with an identity.property. detail.entities lists the refused names, detail.eligible the ones you can use. |
422 | no_eligible_entities | No entity of the published config has a HubSpot schema with an identity.property. |
Replaying a failed record
Finding replayable records
GET /api/connectors/{connector_id}/sync-events returns the most recent
records, optionally filtered by status. Every row carries a boolean
replayable, so the dashboard (and your own tooling) can show a Replay
action without a second round trip:
{
"id": "3f2c...",
"entity_name": "order",
"status": "parked",
"direction": "inbound",
"external_id": "A-1001",
"error_detail": "plugin requested retry: company not synced yet",
"replayable": true
}replayable is true only when all three conditions hold:
- the status is
failed,partial_success,parked, orquarantined; - the row carries a captured
data_snapshot(rows predating replay support do not); - the direction is
inboundoroutbound, the two directions the platform can re-run.
Query parameters
| Parameter | Meaning |
|---|---|
status | Only rows with this status (one of the statuses above). |
store_id | Only rows for this store. |
since, until | Only rows created in [since, until), ISO 8601 timestamps. |
id | Only the rows with these ids. Repeat it for several (?id=...&id=...). |
external_id | Only the rows of the record with this key, exact match: every attempt at it, whatever its outcome. Rows without a key never match. |
skip_kind | Only the rows where a flow was skipped for this reason (one of the values in Why a record was skipped): the skipped rows, and the success rows where another flow of the same event still wrote. The filter reads every row of the window, so pass since with it. |
limit | Maximum rows returned, default 20, at most 100. |
offset | Skip this many rows of the newest-first order, default 0, to page past limit. |
Any other query parameter is rejected with a 422 whose detail names it
(for example page or created_after): the list is never silently
unfiltered.
Rows are ordered newest first, so events that arrive while you page push
older rows down and the next page can repeat a row you already have:
deduplicate by id. For a fixed window, pass the same until on every page.
To re-read the current state of rows you have already seen, pass their ids.
An id that does not exist, or belongs to another connector, returns no row,
so a lookup never answers with a different record. Passing more ids than
limit is a 422 rather than a truncated answer: raise limit (up to 100)
or split the lookup.
Target record id: target_id and hubspot_id
Each row carries the id of the record written on the target twice, with the
same value: target_id (an empty string when nothing was written) and
hubspot_id (null when nothing was written). Use either; they never
disagree.
For a batch view, GET /api/connectors/{connector_id}/sync-events/summary
returns a zero-filled count per status in one call, scopable with since and
until. Add include=skips_by_flow to also get, for each flow, how many of
its executions in the window stopped themselves, by skip_kind (without it
the field is absent). The breakdown reads the step trace of every row in the
window, so pass since with it and do not request it on every poll:
{
"total": 20,
"counts": { "success": 0, "skipped": 20, "failed": 0, "dlq": 0,
"partial_success": 0, "quarantined": 0, "parked": 0 },
"skips_by_flow": { "contact_sync": { "echo_suppressed": 20 } }
}It holds one count per flow per sync event in the window (the event’s latest
attempt): a flow skipped as an echo while another flow
of the same event wrote is counted here even though its row is a success.
The three reasons that stop no flow (when_false, missing_ref,
no_write_step) are only on the row, so filter them with skip_kind
instead.
Triggering the replay
curl -X POST \
https://app.plugsync.com/api/connectors/{connector_id}/sync-events/{event_id}/replayThe replay re-submits the row’s stored data_snapshot down the same path a
live event takes, so it gets the full retry and error-classification
behaviour rather than a second execution path. The response identifies the
new run:
{ "status": "queued", "message_id": "abc...", "trace_id": "replay-3f2c...-9d1e4b7a" }The replayed event is pinned to the config version that is active at replay time, which makes the obvious repair loop work as expected: fix the config, publish, replay. The replay never runs against the stale version that produced the original failure.
The replayed event keeps its original event time: $.occurred_at resolves
to the same instant as on the first run, not to the moment you triggered the
replay. A step or a plugin that orders or deduplicates by event time therefore
sees the replay as the same event, not as a newer one. The same holds for
most_recent conflict arbitration when
it falls back to the event time: a replayed event competes with its original
time. Rows recorded before the event time was stored replay with the replay
instant instead.
The original event time is not used as evidence for
reject_stale_source. A
partial_success record was already written once by the first run, after the
event time, and that write must not make the replay look stale: the replay
runs the flow again from the first step. When the connector declares a
source_modified_path that resolves on the record, reject_stale_source
keeps comparing against that record-level time, on a replay like on any other
event.
A quarantined record uses the same endpoint with ?force=true. That is a
deliberate confirmation step: releasing a quarantine applies the event as-is
and bypasses the entity’s identity_guard for that one replay, so it cannot
happen by accident. For a record whose HubSpot target was deleted, the forced
replay creates it again and links the external id to the new record. See
Target record deleted
for the whole sequence.
| Status | Meaning |
|---|---|
404 | No such sync event on this connector. |
409 | The event is quarantined and force=true was not passed; or its status is not replayable; or it has no captured data_snapshot; or its direction is not replayable. The detail names which. |
These endpoints authenticate as your dashboard user, not with the connector’s event API key.
Record mode: full payload archive and range replay
Per-record replay works from the data_snapshot captured on failure rows.
Record mode is the broader option: it archives the raw payload of every
event crossing the connector, successes included, in both directions. It is
the substrate for parallel runs and for re-running a whole window of traffic.
It is opt-in and off by default. Enable it in the connector config and publish:
{ "settings": { "record_mode": true } }Retention is an org-level setting (record_retention_days, set by an owner
via PATCH /api/orgs/{id}). The archive expires objects on fixed lifecycle
classes: 7, 30, 90, 180, 365 days, default 30. Only those five values
are accepted: any other number answers 422.
| Method | Path | Effect |
|---|---|---|
GET | /api/connectors/{id}/records?from=…&to=… | Browse the archive. Each item’s record_key addresses a single-record replay. Paginated by cursor; the window cannot exceed 366 days. |
POST | /api/connectors/{id}/records/replay | Replay one archived record, whatever its original outcome. Synchronous. |
POST | /api/connectors/{id}/records/replay-range | Replay a recorded_at window as a paced background job. The body names the window as window_from and window_to (ISO 8601, window_to after window_from, at most 366 days apart), optionally narrowed by direction, source_name and event_type. Answers 202 with the job. |
GET | /api/connectors/{id}/records/replay-jobs[/{job_id}] | Follow a range job. |
POST | /api/connectors/{id}/records/replay-jobs/{job_id}/cancel | Stop a queued or running job at the next slice. Already-enqueued events cannot be recalled. |
A range replay is paced to protect the target: 60 events per minute by
default, 600 maximum (pace_events_per_minute). It can also be aimed at
a different connector of the same org via target_connector_id, which is
how a parallel run against a mirror connector is done without touching the
live one.
Two failure modes worth anticipating: a 409 means record mode was never
enabled on this connector and its archive is empty (enable it, publish, and
let events flow first), and a 423 means the target connector is paused or
in error, which would leave the replayed messages unconsumed.
The dlq status
dlq is the last-resort holding state for an event that never reached a
classified outcome at all — a platform-level fault repeating on every
attempt. It is platform-managed: there is no public endpoint to inspect or
release it.
The important part is what does not land there. A classified application
failure never ages into dlq: once the retry budget is exhausted, the
terminal failed / parked / partial_success row is written and then
the event stops, so the failure shows up in your sync events, with its
payload, ready to replay.
Operationally, the platform is alerted on the first event to reach this
state, not on a rate; such an event is retained 14 days; and every
operator action on it is audit-logged with actor and timestamp. If a record
of yours reports dlq, it is a platform incident, not a config error:
contact support with the event id.
Monitoring a connector
The numbers on a connector’s overview page in the dashboard come from a handful of read endpoints. You can call the same endpoints to feed your own monitoring:
| Endpoint | What it answers |
|---|---|
GET /api/connectors/{connector_id}/stats | The headline numbers: last sync, the last 24 hours, events consumed this month. |
GET /api/connectors/{connector_id}/stats/timeseries | Records per day or per hour, split by outcome, optionally per entity and direction. |
GET /api/connectors/{connector_id}/errors/summary | Recent failures grouped by error message, with sample records. |
GET /api/connectors/{connector_id}/identity-mappings/count | How many records the connector’s identity map holds. |
GET /api/connectors/{connector_id}/sync-jobs | Historical sync job runs of an earlier sync engine. |
GET /api/connectors/{connector_id}/stats/timeline | The same historical runs, summed per hour or per day. |
All of them are readable by any member of the organization and by the
organization API key. A connector of another organization answers 404
with {"detail": "Connector not found"}, exactly like one that does not
exist. None of them changes anything.
Headline numbers: GET /stats
curl https://app.plugsync.com/api/connectors/{connector_id}/stats \
-H "Authorization: Bearer <organization API key>"{
"last_sync_at": "2026-10-14T09:12:31.204000+00:00",
"records_synced_24h": 4180,
"errors_24h": 12,
"quarantined_24h": 1,
"events_this_month": 61520,
"events_this_month_limit": 250000,
"events_total": 1840210,
"events_total_since": null,
"degraded": false
}| Field | Meaning |
|---|---|
last_sync_at | When the connector last synced a record (ISO 8601, UTC), or null if it never did. |
records_synced_24h | Records synced successfully over the last 24 hours. It is a rolling window counted by the hour, not “today so far”, and it adds up to the success column of GET /stats/timeseries?range=24h. |
errors_24h | Records whose first delivery attempt failed over the last 24 hours, plus records that ended in the dlq status. A record that failed and then succeeded on a retry is counted here and in records_synced_24h. |
quarantined_24h | Records deliberately not applied over the last 24 hours (quarantined, see the terminal states). Kept apart from errors_24h: a quarantine is a decision, not a fault. |
events_this_month | Events this connector consumed in the current calendar month (UTC): records synced plus records that failed for good. A failure fixed by a retry counts once, as a success. This is what erodes the organization’s monthly event quota. |
events_this_month_limit | The organization’s monthly event quota. It is shared by every connector of the organization, so it is not a per-connector cap. null means no quota applies. |
events_total | Events consumed since the connector was created. |
events_total_since | null when events_total covers the connector’s whole life. A date when the total could only be counted from that day on: read events_total as “since that date”. |
degraded | true when the counts could not all be read. Some or all of them may then read 0 or be incomplete, which does not mean the connector was idle: retry later. last_sync_at may still be filled in. |
Records over time: GET /stats/timeseries
curl "https://app.plugsync.com/api/connectors/{connector_id}/stats/timeseries?days=7" \
-H "Authorization: Bearer <organization API key>"[
{ "date": "2026-10-13", "success": 4021, "failed": 9, "skipped": 310, "quarantined": 0 },
{ "date": "2026-10-14", "success": 1877, "failed": 3, "skipped": 122, "quarantined": 1 }
]The answer is a JSON array with one row per day (UTC) that had any activity:
a day without records is left out, not returned with zeros. A day whose only
records are in a state none of the four columns counts (for example dlq)
does appear, with every column at 0. The columns
count records per outcome, with the same meaning as on GET /stats:
success (synced), failed (first delivery attempt failed), skipped
(nothing to write, for example an event no flow matched) and quarantined.
| Query parameter | Default | Meaning |
|---|---|---|
days | 7 | How many days back from today to cover, plus today, from 1 to 30: days=7 can answer eight rows. |
range | none | 24h switches to one row per hour over the last 24 hours; date then holds the start of the hour ("2026-10-14T09:00:00+00:00") and days is ignored. No other value is accepted. |
breakdown | none | entity returns one row per day (or hour), entity and direction, with two more fields: entity and direction. A record that could not be attributed to an entity or a direction (an event no flow matched has no entity) reads "unattributed" there. |
direction | none | inbound or outbound: count only that direction. Works with or without breakdown. |
[
{ "date": "2026-10-14", "entity": "contact", "direction": "inbound",
"success": 1650, "failed": 2, "skipped": 40, "quarantined": 1 },
{ "date": "2026-10-14", "entity": "unattributed", "direction": "inbound",
"success": 0, "failed": 0, "skipped": 82, "quarantined": 0 }
]Recent failures: GET /errors/summary
Groups the records that ended failed, dlq, partial_success or
parked by their error message, so a hundred records rejected for the same
reason read as one line:
curl "https://app.plugsync.com/api/connectors/{connector_id}/errors/summary?since=2026-10-13T00:00:00Z" \
-H "Authorization: Bearer <organization API key>"{
"groups": [
{
"error": "Property values were not valid: INVALID_EMAIL ...",
"count": 14,
"records": [
{
"sync_event_id": "b1e4...",
"external_id": "CUST-1042",
"entity_name": "contact",
"created_at": "2026-10-14T08:55:02.118000+00:00"
}
],
"first_seen": "2026-10-13T16:20:44.051000+00:00",
"last_seen": "2026-10-14T08:55:02.118000+00:00"
}
],
"since": "2026-10-13T00:00:00+00:00",
"until": null,
"scanned": 21,
"scan_capped": false
}| Query parameter | Default | Meaning |
|---|---|---|
since | now minus 7 days | Only records at or after this ISO 8601 timestamp. A timestamp without an offset is read as UTC. |
until | none | Only records strictly before this timestamp. |
erroris the complete message, never truncated;countis how many records carry it within the window.recordsholds up to 20 of them, most recent first.sync_event_idis the id to replay the record with.first_seenandlast_seenare the first and last occurrence within the window. Groups are ordered bylast_seen, most recent first, so an error that is still happening comes before a larger one that stopped days ago.- A failed record that carries no error message is not counted in any group.
- At most the 2000 most recent failed records of the window are read.
scannedsays how many were, andscan_capped: truemeans there were more: narrow the window withsince/untilbefore reading the counts as complete.
How many records are mapped: GET /identity-mappings/count
curl "https://app.plugsync.com/api/connectors/{connector_id}/identity-mappings/count?entity_type=contact" \
-H "Authorization: Bearer <organization API key>"{ "count": 18342 }Without parameters, count is the number of mappings the connector’s
identity map holds across every entity, and the answer is immediate. With
entity_type (an entity name of the config), it counts that entity’s
mappings only. That count is computed on request and gets slower as the
connector grows: use it to follow one entity’s progress during an initial
load or a pre-warm, not as a value to
poll every few seconds.
Sync jobs: GET /sync-jobs and GET /stats/timeline
A sync job is a run of an earlier sync engine that processed a batch of
records under a single status and set of counters. No current way of
delivering records creates sync jobs: not event flows (the Event API,
webhooks, the HubSpot journal, pollers), not bulk import,
not file sources, not a pre-warm.
Both endpoints therefore return only the historical runs of that earlier
engine, and an empty list on every connector created since. The counts for
current traffic are in GET /stats and GET /stats/timeseries; a bulk load
and a pre-warm report their progress on their own jobs.
GET /sync-jobs lists the runs, newest first. The shape of a historical run:
{
"items": [
{
"id": "9a0c...",
"entity_name": "contact",
"status": "completed",
"trigger": "manual",
"started_at": "2026-10-14T07:00:00+00:00",
"completed_at": "2026-10-14T07:04:12+00:00",
"records_ok": 980,
"records_failed": 2,
"records_skipped": 18,
"error_message": null
}
],
"total": 1
}status is pending, running, completed or failed, trigger says
what started the run, and total is the number of jobs of the connector.
Page with limit (default 20, at most 100) and offset (default 0).
GET /stats/timeline sums the completed historical jobs per period:
{
"buckets": [
{
"period": "2026-10-14T00:00:00+00:00",
"records_ok": 980,
"records_failed": 2,
"records_skipped": 18,
"job_count": 1
}
],
"range": "7d",
"granularity": "daily"
}range is 24h, 7d (default) or 30d; granularity is daily
(default) or hourly. period is the start of the hour or day (UTC), and
only periods with at least one completed job appear.
How far behind a connector is: the inbound queue
Before deciding whether a backlog should be waited out, replayed or thrown away, you need to know whether it is slow or stuck. Each connector answers that about its own inbound events: how many were received and have not finished yet, how long the oldest of them has been waiting and, when the queue is slow, roughly how long it will take to clear.
curl https://app.plugsync.com/api/connectors/{connector_id}/inbound-backlog \
-H "Authorization: Bearer <organization API key>"{
"connector_id": "3f2c...",
"state": "congested",
"summary": "Events are being processed more slowly than usual.",
"pending_count": 12,
"pending_by_status": { "pending": 3, "processing": 9 },
"oldest_pending_at": "2026-09-25T11:29:00+00:00",
"oldest_pending_age_seconds": 1860,
"finished_recently": 6,
"throughput_window_seconds": 900,
"estimated_wait_seconds": 1800,
"generated_at": "2026-09-25T12:00:00+00:00"
}state is the reading, the other fields are the numbers behind it:
state | Meaning | What to do |
|---|---|---|
empty | No events waiting. summary reads "No events waiting." and the age fields are null. | Nothing: an idle connector is not an error. |
flowing | Events are waiting, the oldest for less than five minutes. | Nothing: this is normal processing. |
congested | The oldest event has waited five minutes or more, but this connector’s events are still finishing. | Wait: estimated_wait_seconds says roughly how long. |
stalled | Events are waiting and none of this connector’s events finished in the last 15 minutes. | Check the circuit breaker and the connector status; if the backlog is not worth keeping, drain it. |
estimated_wait_seconds is only given when state is congested. It is the
number of waiting events divided by the pace at which this connector’s
events finished over the last throughput_window_seconds (finished_recently
of them). It is an estimate, not a promise: a burst of new events, or a pace
that changes, moves it. It is built from your connector’s numbers only:
other organizations’ traffic is never counted in it and never shown. If the
platform is busy, you see it as a slower pace on your own connector, nothing
else.
pending_by_status splits pending_count by the event status, the same
values GET /api/v1/events/{event_id}/status reports for a single event.
Readable by any member of the organization and by the organization API
key, so an automation can poll “is my queue empty yet?” without an owner’s
login. A connector of another organization answers 404, exactly like one
that does not exist.
In the dashboard the same reading is the Inbound queue card on the connector’s overview page, refreshed every 30 seconds: Idle, Flowing, Congested with the estimated wait, or Stalled.
First-sync write cap
Some organizations have a first-sync review: a new connector writes its first 500 records to HubSpot, then pauses until an owner of the organization has checked them and lets it continue. It bounds what a configuration that is valid but wrong can write to a live CRM before a person has looked at the result.
It applies only to the organizations where it is enabled. The review is
switched on per organization and is not on by default, so most connectors
never pause this way. Enterprise and Custom-Dedicated organizations are
never under it. To know whether a given connector is, read its
write_cap block: null means the cap
does not apply to that connector, or that the connector has never been
published: the decision is taken at its first publish.
When it applies and when it trips
- Decided once, at the connector’s first publish. A connector is placed under the cap when its configuration is published for the first time while the review is on for its organization. A connector already published before that is never placed under it, and later publishes never change the decision. A connector already paused by the cap still waits for an owner, whatever plan the organization moves to.
- What counts. Each event that wrote a record to HubSpot counts once, however many of its flows wrote, and so does each bulk import row written to HubSpot. A write that failed, or an event that wrote nothing, does not count.
- A soft ceiling. Writes already in progress when the limit is reached
still land, and an event retried after a write counts again, so the
connector can pause a few records past 500.
tripped_writesgives the count at the pause.
What happens at the pause
The connector’s status becomes paused (a connector in error stays in
error) and every owner of the organization gets an email with the subject
“[Plugsync] connector name paused after its first N records” and a
“Review the connector” link. The connector page shows the banner Paused
after its first N records: owners get a Continue syncing button,
members read “Ask an organization owner to let it continue.” While the cap
holds the connector, the Pause and Resume actions are hidden from the page
header and the settings panel.
Nothing that was already on its way is lost while the connector waits, and the wait has no deadline except for bulk import rows:
| What | While the cap holds the connector |
|---|---|
| Events already queued, from any source | Kept, not processed, and sent again after the release. They have no outcome yet, so no sync event is recorded for them until they run. The Inbound queue card can read Stalled in the meantime: that is the pause, not a fault. |
| Webhook deliveries | Accepted as usual and kept with the events above. |
| New single events on the Event API | Refused with 423 (“Connector is paused, ingestion is blocked”), as for any paused connector. Nothing is queued: send them again once the connector is active. |
| Bulk import jobs | Accepted, with their rows held and a warning on the job, see Held jobs. A row held more than 3 days ends failed with held_expired and can be replayed. |
| Poller, scheduled and file sources | Not run while the connector is paused; they start again once it is active. |
hubspot source | Not read while the connector is paused. HubSpot changes made during the pause may not be delivered afterwards, see The HubSpot journal source. |
| Manual sync and replays | Refused as for any paused connector. |
Checking the cap through the API
GET /api/connectors/{connector_id}, and every connector of
GET /api/connectors, carries a write_cap block next to status:
{
"status": "paused",
"write_cap": {
"state": "tripped",
"limit": 500,
"tripped_writes": 503,
"tripped_at": "2026-10-14T09:12:03Z"
}
}| Field | Meaning |
|---|---|
state | Where the connector is, see the table below. |
limit | The number of records the connector may write before it pauses. |
tripped_writes | How many records it had written when it paused. null until then: the running count is not exposed. |
tripped_at | When it paused. null until then. |
write_cap is null on a connector the cap does not apply to, and on one
never published: the decision is taken at its first publish. A connector
that was in error when the cap paused it stays in error through the
whole cycle, so read paused below as paused or error.
state | Meaning | status |
|---|---|---|
armed | Under the cap and counting. | unchanged |
tripped | Paused, waiting for an owner. | paused (or error) |
releasing | An owner let it continue; the held events are being sent again. | paused (or error) |
settling | Every held event is sent; the connector reopens within about a minute. | paused (or error) |
released | Reopened. The cap never pauses this connector again. | back to active from paused; error stays error |
Letting the connector continue
Only an owner, signed in, can let a paused connector continue: from the Continue syncing button, or with a user session:
curl -X POST \
https://app.plugsync.com/api/connectors/{connector_id}/write-cap/release \
-H "Authorization: Bearer <access token of an owner's session>"Before you do, open a few of the records in HubSpot and check that they landed on the right objects, with the right properties and associations. If something is wrong, fix the configuration and publish it first: the held events run against the configuration published when they run, not the one that wrote the first records.
| Status | Meaning |
|---|---|
200 | Accepted. The body is the connector, with write_cap.state releasing and status unchanged (paused, or error). |
403 owner_required | The caller is a member, or the request carries an organization API key: the check is meant for a person, not for the automation that sent the records. |
404 | No connector with this id in your organization. |
409 write_cap_not_tripped | The cap is not holding this connector: it was never paused by it, or it was already released. |
What happens to the held events
- The held events are sent again first. Events of the same record keep the order they arrived in; there is no order across different records. Each one runs and reports its outcome like any other event, so a failure shows in the sync events as usual.
- The connector stays
pauseduntil they are all back in the queue. New single events still get423and sources are not read, so nothing new overtakes the held events. Webhook deliveries are still accepted and wait their turn. - The connector reopens on its own, usually within a few minutes, more
for a large backlog:
statusgoes back toactive(a connector inerrorstays inerror),write_cap.statereadsreleased, and the dashboard banner Resuming held events disappears. Held bulk import rows resume on their own from then on.
During the resume the connector’s status is not yours to change:
PATCH /api/connectors/{connector_id} with a new status answers 409
write_cap_resume_in_progress, paused included, because the reopening at
the end would undo it. To keep the connector stopped, pause it once it
reads active. Before the release, a PATCH to any status other than
paused or error answers 409 write_cap_release_required: the release
is the only way out of the pause.
If the connector is in error, or the organization’s syncs are suspended,
the held events are not sent until that stop is lifted. From error, set
the connector to paused, the one status change accepted during the
resume: the resume goes on and the connector reopens on its own at the end.
Discarding a backlog: drain
Sometimes the right answer is not to retry a backlog but to throw it away: a bad run emitted the same events three times, a test harness flooded a connector, an upstream export was regenerated and re-sent. Draining a connector discards everything queued on it instead of processing it.
A drained event is gone. Nothing is written to HubSpot or to the target
API, nothing is retried, and there is no replay: the platform does not keep
the message. GET /api/v1/events/{event_id}/status answers drained, which
is terminal, and the only way to sync those records is to send them again
once the drain is off. Drain only when you are prepared to lose what is
queued, and only on the connector that needs it: the effect stops at that
connector, its siblings keep syncing normally.
Bulk import rows are the exception: a
drain discards only the rows of a bulk job that are held (connector paused
or suspended, or an open circuit breaker), and they end failed in the
job’s row ledger, where they can be replayed once the drain is off. Rows of
a bulk job that is not held are written as usual.
Draining a poller source: up to seven days of silence
If the connector has a poller source with
inbound dedup on
(the default), draining it has a second effect that outlasts the drain
itself. The poller records an item as already enqueued as soon as it hands
the event over for processing, before the drain discards it. So once the
drain is off, an item the feed keeps serving unchanged is still treated
as a duplicate and does not come back on its own.
It stays suppressed for seven days, and that window is not refreshed: serving the same item again does not extend it, so the count starts from the enqueue the drain discarded. Seven days after that moment the item is enqueued again by the next poll. Until then, records you may believe are in sync are simply not being sent. This is not a permanent loss, but nothing reports it: the connector looks healthy and the feed looks answered.
If you used the drain as an emergency brake and want those items back sooner, you have three ways out:
- Change the content. An item whose content differs from the version that was drained is a real update and is enqueued on the next poll, with no waiting.
- Wait for the window to lapse. Seven days from the drained enqueue, the unchanged item returns by itself.
- Re-send it from your side. Once the drain reads
enabled: false, send the records again through a path that does not go through the poller’s dedup, such as the Event API. Events sent there run the flows declared for the Event API, not those of the poller source, so the connector needs a flow for them.
The platform does not clear the dedup entries of drained items on purpose: on a feed that re-serves everything on every poll, doing so would put back on the queue, at the very next poll, exactly what the drain was meant to throw away.
Turning it on and off
curl -X PUT \
https://app.plugsync.com/api/connectors/{connector_id}/drain \
-H "Authorization: Bearer <organization API key>" \
-H 'Content-Type: application/json' \
-d '{"enabled": true, "reason": "triple emission from the 02:00 export", "ttl_seconds": 3600}'| Field | Required | Meaning |
|---|---|---|
enabled | yes | true starts discarding, false stops. |
reason | yes, in both directions | Free text, non-empty, up to 500 characters. It is recorded with your identity: a drain is a deliberate erasure, and the log has to say why it was ordered and why it was called off. |
ttl_seconds | no | How long the drain may run before switching itself off. Default 4 hours, minimum 30 seconds, maximum 12 hours (43200). A value outside 30..43200 is rejected with 422. The minimum is twice the time a change takes to reach the whole platform (see below): a shorter window would not be the window the drain actually runs for. |
Who can call it: an owner of the organization, or any request
authenticated with an organization API key (only an owner can create one,
so it carries the same authority). A member gets 403. A connector that
belongs to another organization answers 404, exactly like an id that does
not exist.
Starting a drain that is already running answers 409 and changes nothing:
starting resets the counter of discarded events, so a double submit would
wipe the tally you are watching. Stopping is idempotent, always accepted, and
returns the final drained_count.
Both directions usually take effect within about 15 seconds on every part of the
platform processing that connector. That bound cuts both ways: a few more
events can still be discarded in the seconds after you switch a drain off, so
wait for enabled to read false on the endpoint below before re-sending
anything.
It switches itself off
Every drain started this way carries a deadline. When expires_at passes,
the drain stops on its own and the connector goes back to processing normally
with no further call, and the state records ttl_expired as the reason it
ended. This is deliberate: the failure mode worth protecting against is a
drain switched on during an incident and forgotten, quietly erasing
everything that arrives afterwards. If the backlog outlives the deadline,
start a new drain.
A running drain cannot be extended. Sending "enabled": true again while
it runs answers 409 and leaves the deadline where it was. To get a longer
window, stop the drain and start a new one with the ttl_seconds you need.
The new run starts its drained_count from zero: note the final count the
stop returns if you need the total for both runs.
Checking the state
curl https://app.plugsync.com/api/connectors/{connector_id}/drain \
-H "Authorization: Bearer <organization API key>"{
"connector_id": "3f2c...",
"enabled": true,
"reason": "triple emission from the 02:00 export",
"started_by": "ops@example.com",
"started_at": "2026-09-11T21:00:04+00:00",
"expires_at": "2026-09-11T22:00:04+00:00",
"drained_count": 14000,
"last_drained_at": "2026-09-11T21:06:12+00:00",
"stop_reason": null,
"stopped_by": null,
"stopped_at": null
}Readable by any member of the organization, owner or not: “why did nothing land last night” is a question anyone debugging should be able to answer without asking for permissions.
enabled is the answer to “is this connector draining right now”: it
already accounts for an elapsed deadline, so there is no need to compare
expires_at with your own clock. While the drain runs, expires_at is when
it will switch itself off. Once it is over, whether you stopped it or its
deadline did, expires_at equals stopped_at, the instant it ended: a
stopped drain never shows a deadline in the future. drained_count is scoped to the
current run and resets every time a drain is started. After a drain ends,
whether you stopped it or its deadline did, the row stays readable with its
final tally: that number is the record of how much was erased.
A drain started by plugsync support
plugsync support can also start a drain on your connector. It does so only at your organization’s request or, without a request, in the cases where the Terms of Service allow an immediate suspension: for example when the backlog threatens the security, integrity or availability of the platform or of other customers, or on an anomalous traffic spike such as a runaway duplicate export. The drain is limited to the connector concerned.
You are told when it happens. Every owner of your organization receives an email when support starts a drain, with the connector, the reason and the time the drain stops on its own, and another when support stops it, with the final number of discarded events and the reason. Drains you start or stop yourself send no email.
A drain started by support always has a deadline, like the ones you start:
4 hours unless support picks a shorter or longer window, and never more
than 12 hours. When expires_at passes it stops on its own, exactly as
described above.
You always see it: the endpoint above reports enabled, started_at,
expires_at and drained_count exactly as for a drain you started. What
differs is who is named: started_by reads "plugsync support", and
reason carries a note written for you or, when there is none,
"discarding the queued backlog on request of plugsync support". The
emails quote the same reason. A drain that support stops reads the same way
in stopped_by and stop_reason.
You can still stop it, with the same PUT and a reason: it is your data.
plugsync support is notified when you do, so they can follow up with you on
the backlog they were clearing.
Related reference
- API error catalog - every error code of the public API, including the replay and drain errors, with the remedy.
- Event API - the ingestion contract, its status
codes, per-event
/statusand/verify, and how ordering is scoped, which is what decides how a backlog forms in the first place. - Troubleshooting - symptom-first index of the failure modes you are most likely to hit.
settings- whereretryandrecord_modelive in the connector config.- Loop prevention and conflicts -
why a legitimate no-op is reported as
skipped. - Draft and publish - config changes,
including
settings, apply only once published.