Skip to Content
ReferenceReliabilityOverview

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 4xx such as 429) 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.

PropertyValue
Delivery attempts per event5
Default wait between attempts10 s, doubling each time: 10, 20, 40, 80 s (ceiling 160 s)
Default retry horizonroughly 2.5 minutes: four gaps totalling 150 s between the five attempts, plus processing time
How long an unprocessed event survives14 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:

ClassTypical causeBehavior
TransientTarget returned 5xx or 429; a plugin signalled “retry”; a related record has not synced yetRetried, up to the 5-attempt budget. On exhaustion the record becomes parked (or failed, see below).
PermanentAny other 4xx from the target: validation error, unknown object, malformed requestNot retried. Retrying cannot fix it, so the record goes terminal (failed) immediately.
Fatal401 / 403, revoked or dead credentialThe 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:

TriggerCondition
Fatal streak3 consecutive fatal outcomes (see the table above).
Exhausted-retry streak5 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 rateMore 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 the Retry-After delay. Every other flow on the connector keeps syncing normally, and POST /api/v1/events keeps 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 (see 429 below), and the automatic recovery attempt for every flow keeps running on its own schedule.
  • POST /api/v1/events for the affected event type can answer 429 instead of 202 - 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 notify step) 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 failed and 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_echo only 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 with include=skips_by_flow counts 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 } } }
KeyDefaultMeaning
backoff_base_seconds10First 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_seconds160 when backoff_base_seconds is not set, 600 when it isCap 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:

StepWhy 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_manyThe 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 post step 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 an identity block 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 plugin step 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/value store capability 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:

StatusMeaningReplayable
successEvery step of every matching flow completed.No, nothing to retry
skippedNo 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
failedA 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_successPart 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
parkedA 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
quarantinedDeliberately 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
dlqThe 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:

  • parked versus failed. parked means “give it more time and replay it”; failed means “this needs a fix”. A message whose flows produced a mix of outcomes (one flow waiting on a dependency, another genuinely broken) is reported as failed, never parked: a replay alone could not fix it, so the state does not promise that it could.
  • partial_success versus parked. If any step already wrote to HubSpot, the row is partial_success even 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.

StatusWhat it means
successEvery matching flow completed. Once a record is success, a later attempt of the same event never turns its row back into a failure.
partial_successAt 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, parkedNo 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.
skippedThis 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 post step it means the target answered with a 2xx;
  • something was written. An event that wrote nothing is skipped, not success (see the table above).

It does not guarantee that:

  • A REST target applied the change. A post step reads the status code and nothing else: a 200 whose body says {"ok": false}, or a 202 the target later drops, is a delivery like any other. See Response: any 2xx is 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 success says 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 when condition is false does not run, and a flow that stopped itself next to one that wrote still leaves the record success (see the table above). The steps that ran are in the record’s step_trace.

When you need more than that:

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_kindMeaning
echo_suppressedThe 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_sourceThe event was older than what the target already holds (reject_stale_source).
outbound_supersededThe value to send out had already been replaced on HubSpot.
changeset_emptyAfter narrowing to the changed properties, nothing was left to send.
deleted_404The target record no longer exists.
target_deletedThe 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_supersededThe record’s external id was moved to another record.
invalid_emailHubSpot rejected the email value as malformed.
update_record_not_foundAn update step found no record to update. It never creates one.
create_record_existsA 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_stepA skip step stopped the flow.
plugin_skipA plugin answered with a skip.
when_falseEvery step was switched off by its when condition.
missing_refA step referenced an output that was never produced: usually a broken manifest.
no_write_stepThe 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.
unspecifiedA 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 quarantined with target_deleted_quarantined, and from then on every event for that external id is skipped with skip_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.

RequestWhat it doesWhat the next event does
GET /api/connectors/{connector_id}/identity-mappings/record/mappingReads 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/recordRemoves 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/tombstoneRemoves 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 quarantined row with force=true (see Replaying a failed record).
  • Polled feeds with dedup on 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:

ParameterRequiredMeaning
entity_typeyesThe entity name the mapping belongs to.
external_idyesThe external id, matched exactly. It is never a pattern: * is an ordinary character.
store_idnoThe store id, for a mapping written under a store. Without it the mapping with no store is addressed.
reasonnoFree 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).

Statusdetail.errorMeaning
404mapping_not_foundNo mapping at that key. Not treated as success: check the entity name, the exact external id and the store id.
404tombstone_not_foundThe mapping exists and is not marked as deleted (tombstone endpoint only).
404none (detail is Connector not found)Unknown connector, or one that belongs to another organization.
403owner_requiredThe caller is a member, not an owner or an organization API key.
409mapping_changedAn event changed the mapping while the request was running. Nothing was removed; read the mapping again and retry.
422invalid_mapping_keyA 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.
502identity_store_unavailableThe 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"}}.

EndpointVerbPurpose
/api/connectors/{connector_id}/identity-prewarmPOSTStart a job. 202 with the job.
/api/connectors/{connector_id}/identity-prewarmGETThe connector’s 20 most recent jobs, newest first: {"jobs": [...]}.
/api/connectors/{connector_id}/identity-prewarm/{job_id}GETOne job and its progress.
/api/connectors/{connector_id}/identity-prewarm/{job_id}/findingsGETThe records the job did not map, and why.
/api/connectors/{connector_id}/identity-prewarm/{job_id}/resumePOSTContinue a failed or interrupted job from its last saved page. 202 with the job.
/api/connectors/{connector_id}/identity-prewarm/{job_id}/cancelPOSTCancel 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"]}'
FieldDefaultMeaning
dry_runfalseCount what would be mapped per entity and write nothing.
modified_afternoneIncremental 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_idnoneOnly 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.
entitiesevery eligible entityLimit 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 }
  • status is one of the statuses listed in the CLI reference: running, then completed, partial_failure, failed, cancelled, superseded or expired (queued may also appear before the job starts). Poll GET .../identity-prewarm/{job_id} until it leaves running.
  • resumable tells you whether the job’s state allows a resume: it is queued, failed, or running without progress for more than five minutes. Only the connector’s latest job can be resumed, so an older job can read resumable: true and still be refused with job_not_resumable.
  • origin is "org" for a job started from your organization and "admin" for one started by plugsync support; requested_by is the user’s email or api-key:<label>.
  • Per entity, phase is scan, apply, done or failed, and error says why an entity failed. The counters are the ones the CLI prints: scanned is Scanned, mapped is Mapped, already_present is Present, conflicting is Conflicts, ambiguous_values is Ambiguous (held by ambiguous_records records), mapped_elsewhere counts the records already mapped under another external id, and Skipped is missing_value + invalid_value + not_modified.

GET .../findings pages through the records that were not mapped, ordered by entity, value and HubSpot id:

Query parameterDefaultMeaning
findingallambiguous, conflict or mapped_elsewhere.
limit200From 1 to 1000.
offset0Rows 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):

Statusdetail.errorMeaning
404connector_not_foundUnknown connector, or one that belongs to another organization.
404job_not_foundNo job with that id on this connector.
403owner_requiredThe caller is a member, not an owner or an organization API key.
409no_active_configThe connector has no published version yet. The pre-warm maps the entities of the published config: publish first.
409prewarm_already_runningOne pre-warm runs per connector at a time. Wait for it, or cancel it.
409job_not_resumableOnly 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.
409job_not_cancellableThe job already ended as completed, partial_failure, superseded or expired; detail.status says which. Cancelling a job that is already cancelled answers 200.
422entity_not_eligibleAn 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.
422no_eligible_entitiesNo 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:

  1. the status is failed, partial_success, parked, or quarantined;
  2. the row carries a captured data_snapshot (rows predating replay support do not);
  3. the direction is inbound or outbound, the two directions the platform can re-run.

Query parameters

ParameterMeaning
statusOnly rows with this status (one of the statuses above).
store_idOnly rows for this store.
since, untilOnly rows created in [since, until), ISO 8601 timestamps.
idOnly the rows with these ids. Repeat it for several (?id=...&id=...).
external_idOnly the rows of the record with this key, exact match: every attempt at it, whatever its outcome. Rows without a key never match.
skip_kindOnly 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.
limitMaximum rows returned, default 20, at most 100.
offsetSkip 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}/replay

The 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.

StatusMeaning
404No such sync event on this connector.
409The 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.

MethodPathEffect
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/replayReplay one archived record, whatever its original outcome. Synchronous.
POST/api/connectors/{id}/records/replay-rangeReplay 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}/cancelStop 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:

EndpointWhat it answers
GET /api/connectors/{connector_id}/statsThe headline numbers: last sync, the last 24 hours, events consumed this month.
GET /api/connectors/{connector_id}/stats/timeseriesRecords per day or per hour, split by outcome, optionally per entity and direction.
GET /api/connectors/{connector_id}/errors/summaryRecent failures grouped by error message, with sample records.
GET /api/connectors/{connector_id}/identity-mappings/countHow many records the connector’s identity map holds.
GET /api/connectors/{connector_id}/sync-jobsHistorical sync job runs of an earlier sync engine.
GET /api/connectors/{connector_id}/stats/timelineThe 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 }
FieldMeaning
last_sync_atWhen the connector last synced a record (ISO 8601, UTC), or null if it never did.
records_synced_24hRecords 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_24hRecords 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_24hRecords 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_monthEvents 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_limitThe 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_totalEvents consumed since the connector was created.
events_total_sincenull 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”.
degradedtrue 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 parameterDefaultMeaning
days7How many days back from today to cover, plus today, from 1 to 30: days=7 can answer eight rows.
rangenone24h 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.
breakdownnoneentity 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.
directionnoneinbound 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 parameterDefaultMeaning
sincenow minus 7 daysOnly records at or after this ISO 8601 timestamp. A timestamp without an offset is read as UTC.
untilnoneOnly records strictly before this timestamp.
  • error is the complete message, never truncated; count is how many records carry it within the window.
  • records holds up to 20 of them, most recent first. sync_event_id is the id to replay the record with.
  • first_seen and last_seen are the first and last occurrence within the window. Groups are ordered by last_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. scanned says how many were, and scan_capped: true means there were more: narrow the window with since/until before 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:

stateMeaningWhat to do
emptyNo events waiting. summary reads "No events waiting." and the age fields are null.Nothing: an idle connector is not an error.
flowingEvents are waiting, the oldest for less than five minutes.Nothing: this is normal processing.
congestedThe oldest event has waited five minutes or more, but this connector’s events are still finishing.Wait: estimated_wait_seconds says roughly how long.
stalledEvents 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_writes gives 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:

WhatWhile the cap holds the connector
Events already queued, from any sourceKept, 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 deliveriesAccepted as usual and kept with the events above.
New single events on the Event APIRefused 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 jobsAccepted, 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 sourcesNot run while the connector is paused; they start again once it is active.
hubspot sourceNot 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 replaysRefused 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" } }
FieldMeaning
stateWhere the connector is, see the table below.
limitThe number of records the connector may write before it pauses.
tripped_writesHow many records it had written when it paused. null until then: the running count is not exposed.
tripped_atWhen 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.

stateMeaningstatus
armedUnder the cap and counting.unchanged
trippedPaused, waiting for an owner.paused (or error)
releasingAn owner let it continue; the held events are being sent again.paused (or error)
settlingEvery held event is sent; the connector reopens within about a minute.paused (or error)
releasedReopened. 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.

StatusMeaning
200Accepted. The body is the connector, with write_cap.state releasing and status unchanged (paused, or error).
403 owner_requiredThe 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.
404No connector with this id in your organization.
409 write_cap_not_trippedThe cap is not holding this connector: it was never paused by it, or it was already released.

What happens to the held events

  1. 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.
  2. The connector stays paused until they are all back in the queue. New single events still get 423 and sources are not read, so nothing new overtakes the held events. Webhook deliveries are still accepted and wait their turn.
  3. The connector reopens on its own, usually within a few minutes, more for a large backlog: status goes back to active (a connector in error stays in error), write_cap.state reads released, 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}'
FieldRequiredMeaning
enabledyestrue starts discarding, false stops.
reasonyes, in both directionsFree 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_secondsnoHow 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.

  • 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 /status and /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 - where retry and record_mode live 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.
Last updated on