Authoring a flow
Two end-to-end walkthroughs - one inbound, one outbound - then a decision table for picking the right action.
A. Inbound walkthrough - webhook to HubSpot company
Scenario: a shop sends a POST to a webhook whenever a company record changes. Plugsync should upsert that company in HubSpot.
Full config, sources included: _examples/inbound-webhook-to-hubspot.json.
Step 1 - Declare the EventSource
An EventSource’s definition is part of the config document, under its sources key, and the flow’s source field references it by name. Declare shop_webhook there, next to the entity and the flow you are about to write:
{
"name": "shop_webhook",
"type": "webhook",
"config": {
"inbound": {
"url_template": "/api/webhooks/{connector_id}/shop_webhook",
"verify_signature": "hmac_sha256",
"signature_header": "x-shop-signature",
"event_type_path": "$.event"
}
}
}Step 4 sends it to the draft along with everything else, so the source and the flow that reads it go live on the same publish - there is no order to get right. POST /api/connectors/{connector_id}/sources with the same body works too and lands in the same place, answering 202.
No secret here: it is not part of a definition and a secret in that POST body is refused with 422 secret_not_declarable. A webhook source’s secret is generated by the publish that creates the source and handed over once, in that publish’s created_sources; afterwards POST .../sources/shop_webhook/rotate-secret issues a new one on the spot. See event-sources.md for the full API and field reference.
Step 2 - Define the entity
The company entity has a single hubspot schema. Because there is exactly one schema and it is named hubspot, plugsync classifies this entity as simple mode - no canonical schema, no transform step needed. See ../config-v3/simple-vs-federated.md.
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.name", "target": "name"},
{"source": "$.payload.vat_number", "target": "hs_tax_id"},
{"source": "$.payload.city", "target": "city"},
{"source": "$.payload.country", "target": "country"}
]
}
}
}identity.property: "hs_tax_id" is what drives the match: on a record plugsync has not seen before, the upsert searches HubSpot by tax ID and creates a company only if none matches. strategy: "match_or_create" is a label for that intent and does not change the behavior (see entities.md). The four field_mappings read directly from $.payload.* - the inbound webhook body.
Step 3 - Write the flow
{
"name": "company_upsert",
"source": "shop_webhook",
"event_type": "upsert_company",
"event_type_from": "$.event",
"steps": [
{
"id": "upsert_hubspot",
"action": "upsert",
"target": "hubspot",
"entity": "company",
"data": "$.payload"
}
]
}Two points to note:
event_type_from: "$.event"records that the event type string lives at$.eventin the webhook body. This is a forward-compat hint only - it is persisted on the flow definition but is not resolved by the runtime. The webhook receiver is responsible for extracting the event type and placing it on the message envelope asevent_typebefore flows are matched. See ../config-v3/flows.md.data: "$.payload"passes the entire inbound payload to the upsert step. Thefield_mappingsdeclared on thehubspotschema then extract the individual properties.
If the source emits a whole family of related event types (cliente.creato, cliente.aggiornato, …), one flow can cover all of them with a suffix wildcard instead of a copy per event type: "event_type": "cliente.*". See ../config-v3/flows.md#wildcard-event_type for the supported syntax and the most-specific-match-wins precedence rule.
Step 4 - Deploy and publish
Save the full config (entities + flows + sources + settings) to the draft, then publish from the dashboard. The body is sent flat - the config object directly, no {"config": ...} wrapper. PATCH /draft’s legacy wrapper key is patch, not config (see ../config-v3/draft-publish.md#legacy-wrapper-deprecated); sending {"config": {...}} here does not fall back to the deprecated wrapper - it lands as a flat merge patch whose one key happens to be config, which fails with a 422 (unknown top-level config key(s) ['config']) once the merge is validated.
curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/draft \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"version": "3.0",
"entities": [
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.name", "target": "name"},
{"source": "$.payload.vat_number", "target": "hs_tax_id"},
{"source": "$.payload.city", "target": "city"},
{"source": "$.payload.country", "target": "country"}
]
}
}
}
],
"flows": [
{
"name": "company_upsert",
"source": "shop_webhook",
"event_type": "upsert_company",
"event_type_from": "$.event",
"steps": [
{"id": "upsert_hubspot", "action": "upsert", "target": "hubspot",
"entity": "company", "data": "$.payload"}
]
}
],
"sources": [
{
"name": "shop_webhook",
"type": "webhook",
"config": {
"inbound": {
"url_template": "/api/webhooks/{connector_id}/shop_webhook",
"verify_signature": "hmac_sha256",
"signature_header": "x-shop-signature",
"event_type_path": "$.event"
}
}
}
],
"settings": {}
}'Then open the dashboard, click “Review changes”, and publish as v1. That publish creates the shop_webhook source and returns its signing secret once, in created_sources - copy it, it is not readable again. The running connector usually picks the new version up within a few seconds; GET /api/connectors/{id}/versions/active confirms which version is live.
B. Outbound walkthrough - HubSpot change to external CRM
Scenario: when a company is updated in HubSpot, push the change to an external CRM via its REST API.
Full config, sources included: _examples/outbound-hubspot-journal-to-api.json.
Step 1 - Declare the EventSources
Two sources are needed: a trigger source that polls HubSpot, and a target source that describes the external API. Both are entries of the config’s sources key, published together with the flow that uses them:
{
"sources": [
{"name": "hubspot_journal", "type": "hubspot", "config": {}},
{
"name": "crm_api",
"type": "rest",
"config": {
"outbound": {
"base_url": "https://api.example-crm.com",
"credential_name": "crm_api_key",
"auth_type": "bearer"
}
}
}
]
}Trigger - the HubSpot journal source (type: "hubspot", config: {}). The HubSpot journal v4 API is polled every 30 seconds, and the read position is persisted so no events are missed or replayed. See event-sources.md for the enriched payload shape.
Target - the external CRM REST source (type: "rest"). credential_name must match the name of an existing credential in your org (create credentials via the dashboard under Settings -> Credentials); the publish is refused until that credential exists. At runtime the credential is resolved and the appropriate Authorization header is injected on every outbound request. See event-sources.md for the full config.outbound field reference.
Step 2 - Write the flow
{
"name": "hubspot_company_to_api",
"source": "hubspot_journal",
"event_type": "0-2.update",
"steps": [
{
"id": "push_api",
"action": "post",
"target": "crm_api",
"entity": "company",
"url": "/api/companies/upsert",
"body": "$.payload.object.properties"
}
]
}event_type: "0-2.update" uses the HubSpot journal format {objectTypeId}.{action} lowercased. 0-2 is the HubSpot object type ID for companies; update is the action emitted by the journal. Other examples: 0-1.create (contact created), 0-3.update (deal updated), 0-2.delete (company deleted); the action is always one of create, update or delete. See event-sources.md for the full event type table.
body: "$.payload.object.properties" sends the HubSpot property bag to the CRM API. The url is appended to base_url, so the actual request goes to https://api.example-crm.com/api/companies/upsert. This is the explicit-body pattern: body always wins over any crm_api schema on the company entity, so none is declared here. Omit body instead (keep entity) to have post project a entities.<entity>.schemas.<target>.field_mappings list into the outbound body automatically - see step-actions.md#body-projection. Declaring field_mappings on a non-hubspot schema that is neither a body-less post step’s target nor a transform step’s from_schema has zero runtime consumers; see entities.md for the exhaustive list of what reads what.
Step 3 - Deploy and publish
The body is sent flat, same note as step 4 of the inbound walkthrough above - no {"config": ...} wrapper (that shape is PATCH /draft’s legacy patch wrapper only, never config; see ../config-v3/draft-publish.md#legacy-wrapper-deprecated).
curl -X PATCH https://app.plugsync.com/api/connectors/<connector_id>/draft \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"version": "3.0",
"entities": [
{
"name": "company",
"schemas": {
"hubspot": {
"object_type": "companies",
"identity": {"strategy": "match_or_create", "property": "hs_tax_id"},
"field_mappings": [
{"source": "$.payload.object.properties.name", "target": "name"}
]
}
}
}
],
"flows": [
{
"name": "hubspot_company_to_api",
"source": "hubspot_journal",
"event_type": "0-2.update",
"steps": [
{"id": "push_api", "action": "post", "target": "crm_api",
"entity": "company", "url": "/api/companies/upsert",
"body": "$.payload.object.properties"}
]
}
],
"sources": [
{"name": "hubspot_journal", "type": "hubspot", "config": {}},
{
"name": "crm_api",
"type": "rest",
"config": {
"outbound": {
"base_url": "https://api.example-crm.com",
"credential_name": "crm_api_key",
"auth_type": "bearer"
}
}
}
],
"settings": {}
}'Then publish from the dashboard: “Review changes” -> “Publish v1”. That publish creates both sources.
C. Choosing an action
Pick the action by intent; follow the link for the field-level signature.
| Your intent | Action | Field reference |
|---|---|---|
| Create if missing, update if present | upsert | step-actions.md |
| Insert only | create | step-actions.md |
| Modify an existing record only | update | step-actions.md |
| Remove a record | delete | step-actions.md |
Reshape the payload into $.canonical | transform | step-actions.md |
| Look up a target record before acting | find | step-actions.md |
| Push to an external REST API | post | step-actions.md |
| Best-effort side notification (must not abort the flow) | notify | step-actions.md |
| Custom code (HTTP, branching, state) | plugin (see the Plugin runtime guide) | step-actions.md |
| Conditionally do nothing / abort a branch | skip + when | step-actions.md |
| Link two already-upserted objects one pair at a time | associate | step-actions.md |
| High-volume batch write to HubSpot (v3.1) | upsert_many / create_many / update_many / delete_many | step-actions.md |
| Bulk-create associations from pre-built pairs (v3.1) | associate_many | step-actions.md |
A typo in a step field saves, then warns. A step accepts keys it does not know, so that you can attach your own annotations to it: a misspelled field name therefore saves to the draft without error, and the engine ignores it at runtime. The publish preview and the publish itself report it as an
unknown_step_keywarning, with a “did you mean” suggestion when a known field is close and the list of fields the action reads. A field that exists but belongs to another action (for examplefieldson a step that does not take it) is refused when the draft is saved, or reported as a warning at publish for the fields that have no save-time check. A warning does not block the publish, so read the warnings before you publish and copy field names exactly from the reference above.