This page is the standard for how an integration in Connection Creator is built. It applies to a person building one by hand and to an AI agent generating or reviewing one, and it is deliberately stricter than what the editor technically allows.
How to integrate anything explains the method: what to do first, second and third. This page explains what a finished integration has to look like, and why.
It is written to be checkable. Almost every rule here can be verified by opening a blueprint and looking, which is the point: consistent integrations can be filtered, compared and debugged together, and inconsistent ones can only be read one by one. The rule set at the end carries an identifier for each rule, so a finding can be named rather than described, and most of those rules are checkable by a program.
The one idea
An integration is a translator, not a repair shop.
Its job is to take a lead that is already correct and say it in the advertiser’s vocabulary. Every time an integration does something else, whether that is fixing a malformed value, inventing a field, re-shaping a response or deciding what a good phone number looks like, that work is being done in the wrong place, once per advertiser, and it will be done differently the next time.
Three places can do work, and they are not interchangeable:
- Structure decides what a valid value is: formats, required fields, validation, and any normalisation every advertiser would need anyway.
- Transform data decides what this advertiser calls things: enum mapping, their date format, their limits, their prefixes.
- The HTTP node puts the request together: composing values, static values, and type conversion. Every form field arrives as a string, so a number or a boolean is made at the moment the request is built, never earlier.
If a transformation would be needed for every advertiser, it does not belong in an integration at all. Fix the structure once and every integration built afterwards gets it for free.
Fields and values
Map to the closest PalDock field
Pick the PalDock field that means what the advertiser’s field means, not the one whose name looks similar. Global fields first, because integrations from the library are built against them and because the same field then means the same thing in every workspace.
If nothing fits, a local field is the answer, and it is a workspace-level decision, not something to invent inside one blueprint.
Modify the original field, never a parallel one
This is the single most common mistake in generated blueprints.
When the advertiser needs income_type in their own vocabulary, the mapping is written into {income_type}. Not into income_type_extra, not into income_type_sub, not into income_type_advertiser.
✗ Field: {income_type_sub}
source {income_type}, equals full-time → EMPLOYED
✓ Field: {income_type}
equals full-time → EMPLOYED
A Modify Field section already takes the field’s own value as its source and writes the result back. The parallel field adds a name nobody else uses, a source reference that can go stale, and a second thing to keep in sync. It buys nothing, because the original value is not needed again.
A helper field is justified in exactly two cases:
- The original value is still needed later in the same flow, in its original form. Sending both
amountand a cappedamount_maxis a real case. - One source field feeds two advertiser fields that need different transformations. Splitting a full name into two parts is a real case.
When you do need one, name it for what it holds and give it the custom_ prefix, as {custom_amount_capped}. Do not name it after the field it was derived from with a suffix bolted on.
Compose in the HTTP node, do not compose in Modify
Putting two values next to each other is not a transformation. Any field in an HTTP node accepts free text mixed with references, so write it there:
✗ Modify: {custom_permanent_street} = {address_street} + prefix/postfix chain
HTTP: permanent_street = {custom_permanent_street}
✓ HTTP: permanent_street = {address_street} {address_street_number}
The same goes for a fixed value the advertiser requires, a header built from a token, or a URL assembled from an endpoint key and a path. If there is no condition and no logic, it belongs in the request.
Convert types in the HTTP node, never in Modify
Every form field is a string. {amount} is “5000”, not 5000, and {consent} is “true”, not true. That is true of every field in every structure, so a type is never a property the value already has: it is something one advertiser’s API asks for and another does not.
That puts conversion in the same category as composition and static values. It is part of building the request, so it goes in the request, taken from the picker in the field itself:
✗ Transform data: {amount} to_int
HTTP: amount = {amount}
✓ HTTP: amount = toInt({amount})
Two reasons.
- Opening the HTTP node should tell you the exact shape of every key going out. If half the types are decided in a Transform data node three steps earlier, that is no longer true of any of them, and a difference between two advertisers that is purely about their request format is buried in a node that is supposed to hold their vocabulary.
- A converted field is also no longer the form’s value. Anything reading it later, a condition, a second request, a reject mapping, reads whatever the conversion left behind rather than what the person filled in. Converting in the request leaves the field alone and gives each advertiser the type they asked for.
Use one conversion, not a nest of them: toBool(toString({value})) is a smell, not a technique. And where the advertiser wants a string, write nothing. The value already is one.
Parameter names are snake_case
{redirect_url} is a parameter the platform reads. {redirectUrl} is a new parameter nobody reads, created by a typo, holding the right value where nothing will ever look for it. The same applies to every field and key you write yourself.
When the vocabularies do not line up, choose the answer that keeps the lead alive
Advertisers bucket things differently from your form. Employment length is three months, and their API offers 0 or 12. Neither is right.
Send 12.
Where two vocabularies do not map cleanly and there is no exact option, pick the one that presents the lead as qualifying. The advertiser verifies what actually matters to them later; a lead filtered out at the door for a bucketing difference is a lead nobody gets paid for.
Two limits on this:
- It resolves a gap between two vocabularies. It does not invent data. An answer the person gave as no is never sent as yes.
- It does not apply to what is being bought. Amount, term and product are the request itself, so send what the person asked for and let the advertiser refuse it.
There is one common exception, and it needs agreeing rather than deciding. Where an advertiser publishes a maximum and rejects anything above it outright, sending the maximum instead of the requested figure keeps a lead alive that would otherwise be refused on a formality. Where the advertiser would have made a smaller offer on their own, sending the maximum takes that decision away from them. Ask which of the two they do before capping.
Empty and optional values
An empty required field is a rejected request at some advertisers, even when they do not care about the value. Fill it with a placeholder.
An empty optional field is better left out. Use do-not-send to drop it from the request. Never send the literal string "null" unless the advertiser explicitly documents that they want null.
A field that can only ever be invalid is dropped, not patched. Where a value fails the advertiser’s own validation, sending a trimmed or padded version turns a missing field into an invalid one, and an invalid field is refused harder than an absent one.
Transformations
Format is the structure’s job
Before writing a transformation, ask whether the value should ever have arrived in that shape.
A regex that repairs a postcode, strips spaces from a phone number, or trims a national ID is validation in the wrong place. It belongs in field validation or in the structure’s own Modify, where it runs once, for every advertiser, before the lead is even accepted.
Doing it in the integration means the malformed value still enters PalDock, still goes to every other advertiser unrepaired, and gets patched in fifteen blueprints separately until one of them is missed.
In an integration, transform only where this advertiser genuinely diverges from what the form produces.
That leaves a short list, and it is the list worth having in a Transform data node:
- enum and code mapping into their vocabulary
- their date format
- their amount or term limits
- a prefix or format that is theirs specifically
- conditional values that depend on other fields
One Transform data node per flow, and none at all when there is nothing to transform
All of it goes in one node, placed before the point where the flow branches. One node holds as many sections as you need, and each section is one field, so there is no size at which a second node becomes necessary.
A second Transform data node is justified only when it transforms something that did not exist yet at the first one, such as a value returned by an earlier request. Two nodes acting on form fields, in sequence or on parallel branches, is always a mistake.
The rule is one node at most, not one node always. An empty Transform data node, or one holding a section with no rows, is not compliance. It is a step that runs, logs and explains nothing. The same applies to an empty Store response node. If the flow has nothing to transform or nothing worth storing, delete the node.
Do not transform the advertiser’s response
The response is already data. Use it where it is needed:
✗ Modify: {custom_app_id} = {parsedBody.applicationId}
HTTP: .../applications/{custom_app_id}/accept
✓ HTTP: .../applications/{parsedBody.applicationId}/accept
Two things are worth taking out of a response, and both are storage rather than transformation, so both belong in a Store response Set node: values that must outlive the run, such as {external_id} and {redirect_url}, and values a later step needs after other steps have run in between, such as a token.
The distinction that matters is distance. Storing a value that the very next node reads is a detour with no purpose. Storing a value that three steps and a wait later still needs is exactly what Store response is for.
Reference the response directly with {parsedBody...}, {status}, {body} and {headers} whenever you are handling the response of the step being evaluated. Reference a named earlier step only when you genuinely need data from a different request.
Reaching into the response
Use the index for a single element. {parsedBody.errors.0.error_code} resolves to that element’s value and can be tested, stored or written into a field, as long as the element itself is a string or a number.
Without the index it does not resolve. {parsedBody.errors.error_code} comes back as literal text, and literal text is never empty, so is not empty on it is always true and is empty always false. A branch built on that fires on every response, whatever came back, which is how a Ping flow ends up labelling eight rejections in a row as the same reason and never reaching its own success branch.
Whether an index is needed depends on the response, not on the field name. A property called errors is a list at one advertiser and a map keyed by field name at another. In the first case {parsedBody.errors.0.code} is right and {parsedBody.errors.code} resolves to nothing; in the second it is the other way round. Nothing in the blueprint says which, so this is one of the few things that has to be read from a real response rather than checked mechanically.
What is checkable, without knowing anything about the advertiser, is that one blueprint addresses the same container two different ways. Where {parsedBody.errors.0.code} and {parsedBody.errors.code} both appear in the same integration, the container cannot be both a list and a map, so one of them is silently doing nothing.
A reference that resolves to an object or an array resolves to its JSON text, so a regex operator can be pointed at it. {parsedBody} searches the whole parsed response, {parsedBody.non_field_errors} searches only that array. What a pattern cannot do is pick an element out that way: a match against an array tells you the text is somewhere in it, not which element held it, so use an index when you need the value itself.
Target the narrowest reference that answers the question:
- an indexed element, when you know where the value is
- the array or object that contains it, when the position varies
{body}, the raw response as text, when the shape itself varies
✓ {parsedBody.non_field_errors.0} regex_match already registered an element holding a string
✓ {parsedBody.non_field_errors} regex_match already registered that array as JSON text
✓ {parsedBody} regex_match already registered the whole parsed response as JSON text
✓ {body} regex_match already registered the raw response as text
The wider the target, the more places a pattern can match by accident. A word that appears in the advertiser’s error message may also appear in a field they echo back from the request, so scope the condition to the part of the response that carries the answer.
For {reason_detail}, prefer {body} on an unconditional row. It carries the whole response whatever its shape, so it keeps working when the advertiser adds a field or returns an error nobody has seen yet. Compose from indexed paths only as an additional, conditional row on top of it.
Rows do not chain by default
Every row in a section evaluates its source against the value the field held when the node started, not against the result of the row above it. Rows are independent unless you connect them explicitly.
To read the output of an earlier row, set the Source to that row. The editor inserts a positional reference: {_1} is the result of the first row, {_2} of the second, and so on. The same reference works for any earlier row, not just the immediately preceding one.
1. source {data_city} always → test
2. source {_1} always → … ← reads the result of row 1
3. source {_2} always → …
4. source {_1} always → … ← any earlier row, not only the last
Row indexes are renumbered on save, after rows with neither an operator nor a modification are dropped. A reference set before an empty row was removed can end up pointing at a different row than the one it was set to, so re-check chained rows after editing the section.
A fallback can therefore sit anywhere in the section, as long as its condition is written against the value it will actually see:
- Fallback first. Recommended. It sets the default and the specific rows below override it. Their conditions are written against the form’s vocabulary, which is what they read anyway.
- Fallback last. Only with an explicit
{_N}source pointing at the row whose result it should judge.
Never place a fallback last with the field itself as source. It re-reads the original value and overwrites the mapping that just succeeded. A regex_match ^\d{4}$ → 1 line at the bottom of a seventy-row bank-code mapping re-read the original four-digit code and reset every correctly mapped lead to bank ID 1, across 549 leads, without a single error anywhere.
A negative match listing the values handled above is the one safe way to write a fallback last, and only if it lists them in the form’s vocabulary rather than the advertiser’s. A section that maps full-time → employed and pension → pensioner above, and then ends with regex_not_match ^(employed|pensioner)$, fires for exactly the leads the rows above handled, because the last row still reads full-time, not employed. Every one of them comes out as the default. The same section written as regex_not_match ^(full-time|pension)$ is correct.
Operators and modifications
The operators are listed in Condition Operators and values, the modifications in List of modifications. Those pages say what exists.
Both enums are snake_case: regex_replace, first_regex_match, to_bool. Hyphenated names belong to the previous Connection Creator. They pass the editor’s own check and are then skipped at run time with nothing logged, so the row does nothing and the field keeps the value it already had.
What the reference pages cannot say is which slot a value goes into, and that is where half the rows that silently do nothing come from. A valid rule written into the wrong slot runs, matches nothing, and leaves the field as it was, without raising anything.
- The comparison value goes into value in a Modify row and into output on a connection. Same operator, different slot, and a pattern written into the wrong one never matches.
- Always, Is empty, Is not empty, Is true and Is false take no comparison value. Leave the slot empty.
regex_replaceis the only modification that uses both slots: the search pattern in pattern, the replacement in output, with$1and$2available.- Every other modification leaves pattern empty.
replace,math,prefix,postfix,first_regex_match,format_dateandmodify_datetake their argument in output, and the type conversions take nothing at all.
Three mistakes account for nearly all of it: a regex match row with the pattern in output instead of value, an Always row with something written into value, and a regex_replace with the pattern and the replacement the wrong way round.
One more is worth knowing because of how far it reaches: a row with an operator and no modification fails settings validation, and a step whose settings fail does not run at all. Not the row, the whole node. Every mapping in it stops happening at once, and the run continues as though the node were not there.
Watch the braces
{parsedBody.status} is a reference. parsedBody.status is a string that will never match anything, and the section silently does nothing. This is the most common reason a Modify Field node appears to be ignored.
The same applies to the target of a Modify section and the key of a Set node: written without braces, the name is passed through as a literal and the value lands nowhere.
Flow and branching
Logic belongs on the arrows
A connection is where the flow decides. A node is where the flow acts.
✗ HTTP → Modify (sets a flag when status is APPROVED)
→ Modify (sets a flag when status is DENIED)
→ condition on the flag
✓ HTTP → condition {parsedBody.status} equals APPROVED → Store response
→ condition {parsedBody.status} regex match DEN|CAN → Reject reason
Never add a node whose only purpose is to make a decision that a condition already makes. Conversely, never split one decision across several nodes: twenty rejection responses are twenty sections of one Modify Field node, not twenty branches.
One connection per node pair
At most one connection between the same source and target. Several conditions on one connection are AND-ed; that is what you use when they all have to be true.
If you need alternatives, use a Regex match with |, or genuinely separate branches to different nodes. Two arrows between the same two nodes because you had two conditions is always wrong.
Find the decision before you write the branch
The most common way to break an integration is to guess where the advertiser said yes or no, and guess wrong. There is no house style. Across advertisers the answer turns up in five different places, and more than one puts it somewhere the status code flatly contradicts.
Before writing a single branch, look for the answer in all five:
- A business field in the body. A
status,state,result,resolutionoracceptedfield. This is the usual case and the one to prefer. - The presence of a URL. Some advertisers say yes by handing over somewhere to send the applicant, and say no by returning the same object with that field null.
- An error array or map. Empty on success, populated on failure. Reachable only with the right path.
- The status code alone. Rare, and only safe where the advertiser documents each code as a decision. Some APIs answer with an empty body and nothing else, and then the code is genuinely all there is.
- Nowhere obvious. Some advertisers return an identical
200for both and the difference is a field you would not think to look at, such as which page the redirect points to.
Two traps worth naming, because each has cost real leads:
A status code can mean the opposite of what it looks like. An API whose check is “is this person already known to us” may return 404 to mean we want this lead and 200 to mean we are not interested. Others return business rejections under a non-standard code, or under 400, or under 500. Read the advertiser’s own words, not the RFC.
A 5xx is not always theirs and not always a fault. One and the same status code can carry a plain string saying the channel is switched off, and a validation failure naming a field, and a genuine crash. Mapping the whole status code to one reason hides the commercial problem inside the technical one.
Cover the business outcomes, overlap nothing, and let the rest fall through
Every connection whose condition matches is followed, so alternative branches must be mutually exclusive unless you intend several to run.
If no connection matches, the run stops mid-flow and reports Dead End.
Cover every outcome the advertiser documents as a business result, then catch the remaining business results with a negative match against the known ones:
Approved: {parsedBody.state} Equals APPROVED
Rejected: {parsedBody.state} Regex match DEN|CAN
Pending: {parsedBody.state} Regex not match DEN|CAN|APPROVED
Anchor patterns you mean exactly. Without ^ and $, approved also matches not approved.
A catch-all is scoped to the business response, never to the status code. {status} regex_not_match ^201$ is not coverage, it is a funnel: it sends timeouts, auth failures and gateway errors into the same branch as a genuine rejection, and they come out of the report labelled as one. Build the catch-all on a body field, or pair it with the success status code so that only the advertiser’s own answers can reach it.
Leaving technical responses uncovered is deliberate. They Dead End, and that is the correct outcome, as below.
Say it once
Data travels forward through a flow. A step whose result is already available is a step that should not exist again.
- Auth runs once per flow, when the token stays valid for the later requests. A second Auth node before every HTTP request is duplication, not safety.
- A transformation runs once. If several later paths need the same transformed value, put Transform data before the point where those paths diverge.
- A value already stored is read, not fetched again.
Repeat a step only for a functional reason: a signature that is request-specific, a token that has expired, a source value that has changed, or a genuinely different output format.
Across flows, the rule is the same but the mechanism differs. Ping and Post are separate runs and must never be connected. What passes between them is stored values: if the Ping stored {external_id} or another value the Post needs, the Post reads the parameter instead of asking the advertiser again.
A stored value with a lifetime is the exception. An access token that the Ping obtained may have expired by the time the Post runs, so the Post authenticates again rather than reading it. A runtime token belongs in a custom_ parameter for the length of the run. It is never written back into the secrets table, where it would race with parallel runs and bleed across the test and production toggle.
Loops
There is no retry node. A loop is an HTTP status request with a connection leading back to a Wait for as long as the answer is not final.
Every loop has a Breaker. Without one, an advertiser who never decides takes the run into a system limit instead of a clean end. Watch the synchronous limit while you do it, because the visitor is on a loading screen for the whole loop, and the wait multiplied by the Breaker’s count has to fit inside it.
Cover every status the advertiser documents, including the terminal ones you do not expect to see. A status missing from the loop conditions is a Dead End waiting to happen.
Leave technical errors alone
Branch on what the advertiser says, not on the status code. When they return a documented business result under a 4xx or a non-standard code, branch on it and map {reason} from their response. Business rejections arrive under 400, under 409, and under codes that are not in the standard at all. All of them are business outcomes and all of them are mapped.
What is forbidden is deriving {reason} from a bare status code the advertiser gave no reason for. {status} regex_match ^(401|403|5\d{2})$ → ERROR invents a reason instead of mapping one.
A run that stops on a technical response still produces a result row: PalDock fills {reason} with HTTP 500, HTTP 429 and so on, with status and body in {reason_detail}. Reaching an End node is not required for this. That is why technical responses need no branch at all, and why leaving them to Dead End is not a gap.
Two technical failures are easy to miss because they arrive as 200 with a body that reads like a refusal: an authentication error and an expired or wrong credential, both returned inside a normal-looking response object. Neither is the lead’s fault and neither gets a reject reason. A block of them in a report means the integration is broken, and labelling them as rejections hides exactly that.
Success and rejection
200 is not acceptance
Most advertisers return a rejection with a perfectly normal status code. Decide success on the documented business result in the body. A status code alone is the most common reason an integration reports accepted leads the advertiser never took.
Where the advertiser returns both, check both on the same connection: {status} equals 200 and the business field says what it should.
Every path reaches an End node, and the End tells the truth
Two settings: Success when the other side accepted, Reject when they turned it down. Error is not a setting. It is where a run lands when it never reached an End at all.
In the blueprint these are the literal strings success and failed, and only the exact string failed rejects. A typo, a different word, an empty value, all come out as an accepted lead.
Every rejection path ends in an End node set to Reject. A rejection branch that ends in a Success node is worse than a broken integration, because nothing looks broken: the reports count leads as sold that nobody bought, and the number is wrong everywhere it is used.
Check this on every End node in the blueprint, not only on the one named Rejected.
Set both reason parameters
{reason} is the category. Short, repeated, in English, and what the reports group by. {reason_detail} is what the advertiser actually said. Free text, passed straight through.
{reason_detail} costs one unconditional row copying {body}, and it covers every path at once. Do that before you have mapped a single reason, because those raw strings are the list of responses still to map.
All of the reason mapping goes in one Reject reason Modify node. A flow needs a second one only when a second HTTP stage can independently reject the lead, and then both the Modify and its End are qualified with the stage: Reject reason · Offer → Rejected · Offer.
No fallback reason
{reason} gets a row for each outcome the advertiser actually returns. It does not get an unconditional row at the top setting Unspecified for everything else.
The fallback looks like diligence and behaves like a drain. Anything that reaches the node without a mapping, a rejection code added last week, a maintenance page, a response shape that changed, comes out as a normal rejection with a normal-looking reason, and the report gives no sign that a lead was ever misread. A Dead End for the same response reads as what it is: something arrived that this integration does not understand.
So the shape is:
- Connections branch only on outcomes the advertiser documents, plus a catch-all scoped to the business response.
- Every path that reaches Reject reason is therefore a recognised outcome, and every recognised outcome has its own row.
- Everything else Dead Ends, loudly.
The cost is real and worth naming: when an advertiser introduces a new rejection code, those leads stop producing a report row until someone notices. Dead Ends need watching, exactly as Unspecified would have. The difference is that a Dead End looks like a fault and an Unspecified looks like an answer.
Unspecified stays in the vocabulary. It is a mapping, not a default, used when the advertiser’s own documented answer carries no reason, such as accepted: false with nothing else in the body.
Unspecified, not Not Eligible
This reject reason matters more than it looks.
Not Eligible means the advertiser named a condition the lead failed, and it was a condition you could not have checked in advance. A knockout criterion. Unspecified means the advertiser rejected the lead and did not say why.
The judgement is easier to copy than to define, so here is how it falls out on the kinds of answer advertisers actually return.
Not Eligible, because a condition is named:
- a documented criteria check that comes back as unsatisfactory or does not meet requirements
- an internal knockout code the advertiser publishes with a meaning, such as a register or authority check
- a numeric lead status the advertiser documents as refused by knockout scoring
- employment history too short, or a licence or registration the applicant does not hold
Unspecified, because nothing is named:
rejected,declined,not acceptable,unsuccessful, or a single-word refusal codeaccepted: falsewith nothing else in the body- a two-letter internal code with no published meaning
- no interest, or an application the advertiser will not process further without saying why
- an empty body under a status code the advertiser documents as a refusal
A flat refusal with no stated reason is Unspecified. Every time. It is worth checking Unspecified from time to time, unlike Not Eligible, because it collects the advertiser’s own vague answers, and a change on their side can start landing there.
The reason to be strict about this is not tidiness. Unspecified in a report is a bill you can present to the advertiser: this many leads, refused, no reason given, send us reason codes. Not Eligible reads as a reason that was already given and understood, so nobody ever asks, and the information is lost permanently. Using Not Eligible as a general-purpose rejection bucket quietly removes the leverage the report exists to create.
The same applies to any specific reason: map it only when the advertiser actually said it. Pick reasons from the standard list rather than typing them, so the spelling groups. See Reject reason for the full list and what each one means.
Naming
Good naming means the same step can be filtered and compared across every integration at once. That is what makes logs and audits possible.
Node names
Keep titles short. A title describes the purpose of the step, not what is already visible from the node type or its configuration.
- Start node: leave the title empty
- Main HTTP request in a Ping flow:
Ping - Main HTTP request in a Post flow:
Post - Main HTTP request in a Post Verify flow:
Verify - Data transformation:
Transform data - Response storage:
Store response - Reject mapping:
Reject reason - Authentication request or preparation:
Auth - Status request:
Status - Offer request:
Offer - Accept request:
Accept - Create request:
Create - Wait:
Wait 3s,Wait 10s - Breaker:
Breaker 3x,Breaker 5x - Successful End:
Success - Failed End:
Rejected
The Start node needs no title: its type says Start and its flow says which flow it begins.
The main HTTP request is the exception that must always be named. A flow may hold several HTTP nodes, and the one performing the actual Ping, Post or Verify is named for it so it can be filtered across every integration in the logs.
Repeated steps
Prefer a meaningful qualifier over a number: Status after accept, Offer after verify.
Use a numeric suffix only when two requests genuinely do the same thing with no useful distinction: Offer 1, Offer 2. Never Get status2, Request 2 or HTTP 3.
Use the shortest unambiguous name. Create is enough when there is only one create operation.
Store nodes
A Set node that stores values from a response is always Store response, whatever it stores: {external_id}, {redirect_url}, a token, a temporary process ID, a price. The fields inside explain the rest.
The advertiser’s persistent ID for the lead goes into {external_id}. Temporary identifiers go into a custom_ parameter.
URLs and secrets
Base URLs are integration keys, not text in an HTTP node:
{endpoint} = https://api.example.com
HTTP node: {endpoint}/v1/applications
No trailing slash on the endpoint, paths beginning with /. A genuinely different host gets its own key, such as {endpoint_auth}. Never hardcode production credentials in a blueprint.
Basic Auth always uses {username} and {password}. If the header must be built by hand, Base64 encode {username}:{password} at run time. Do not store a pre-encoded value as a secret: it cannot be rotated one half at a time, it cannot be paired cleanly with a test value, and nobody six months from now will know which half changed.
Secret names come from the allowed list. custom_* is the only free space, and a recurring value that lands there fragments into a different name in every blueprint.
Flow IDs
- Post flow uses
1-*, Ping flow uses2-*where practical. This one is a convention, not a requirement, and an existing blueprint is not rewritten for it. - A Ping flow exists only alongside a Post flow.
- Further independent flows use
3-,4-, and so on. - Independent flows are never connected.
The node id itself is not decorative. The canvas reads it to work out which flow a node belongs to, and refuses a connection between two nodes in different flows, so a generated id has to match the graph it describes.
Anti-patterns
Each of these appears in real blueprints, and each has a one-line correction.
Fields and transformation
- A parallel field, mapping
income_typeintoincome_type_sub→ map into{income_type}itself - A regex repairing a postcode, phone or ID inside the integration → fix it in the structure’s validation or Modify
{custom_permanent_street}built from street plus number in Modify →{address_street} {address_street_number}in the HTTP field- Copying
{parsedBody.x}into a custom field the next node then reads → use{parsedBody.x}in the next node - Two or three Transform data nodes on the same flow → one node, before the branch, with as many sections as needed
- An empty Transform data or Store response node → delete it
- A row with an operator and no modification → complete it or delete it, or the whole node stops running
- A hyphenated modification name → use the snake_case enum
- A fallback line placed last with the field as its own source → put it first, or give it a
{_N}source - A negative fallback written in the advertiser’s vocabulary instead of the form’s → list the form’s values
- A regex match row with the pattern in output instead of value → the pattern goes in value in a Modify row, in output on a connection
- to_int or to_bool as a Transform data row → toInt({amount}) in the HTTP field; form fields are strings, so the type belongs to the request, not to the field. toBool(toString({value})) → one conversion, or none
- Sending
"null"for a missing optional value →do-not-send - Repairing a value that can only ever be invalid → drop the field instead
{redirectUrl}where{redirect_url}was meant → snake_case, always
Graph and branching
- A Modify node whose only job is to set a flag for a later condition → put the condition on the connection
- A Modify node per rejection response → one
Reject reasonnode with one section per response - Two arrows between the same two nodes with different conditions → one arrow, or a regex, or separate branches
- Branching on the advertiser’s status without a catch-all →
Regex not matchagainst the known values - A catch-all built on
{status} regex_not_match ^201$→ scope it to the business response, or pair it with the success status code - A regex against
{body}when the value always arrives in one known field → target that field, so the pattern cannot match elsewhere in the response - The same container addressed with and without an index in one blueprint → decide which it is and use it consistently
- A connection on
{status}400 or 500 whose Reject reason is derived from the status code → delete it, PalDock fills those in. Keep it only when the body carries a documented business result. - An Auth node before every HTTP request → one Auth per flow, while the token is valid
- Post re-fetching something the Ping already stored → read the stored parameter
- A loop without a Breaker → add
Breaker 3x
Outcomes and configuration
- A rejection branch ending in an End set to Success → set it to Reject
- An unconditional
Unspecifiedrow at the top of{reason}→ remove it and let unrecognised responses Dead End Not Eligibleon a refusal with no stated reason →Unspecified- A technical failure mapped to a reject reason → leave it uncovered
format: application/jsonset on one node and assumed on the rest → set it explicitly on every node sending a JSON body- A runtime token written back into the secrets table → keep it in a
custom_parameter for the run
Preflight checklist
Before a blueprint is finished:
Sources
- API documentation read, and real request and response logs reviewed where available
- At least one acceptance and one business rejection tested in the run log
- Where the documentation and the observed responses disagree, the observed behaviour wins, and the difference is written down next to the blueprint
Fields
- Global fields used wherever one fits
- Modify writes into original fields; no parallel
_sub/_extrafields without a stated reason - No formatting repair that belongs in the structure
- Composition and static values are in the HTTP node, not in Modify
- Bucketing gaps resolved in the lead’s favour, and any cap agreed with the advertiser
- Empty optional fields dropped with do-not-send, never sent as
"null" - Every parameter name snake_case
Structure of the flow
- At most one Transform data node per flow, before the branch, and none if there is nothing to transform
- No empty nodes of any kind, and no row with an operator but no modification
- Auth appears once per flow
- Nothing is recomputed that an earlier step already produced
- The advertiser’s response is used directly, not copied into a field the next node reads
- Conditions target the narrowest reference that answers them
- The same container is addressed the same way throughout
- Store response holds
{external_id}and{redirect_url}where returned - Ping and Post are not connected
- Every loop contains a Breaker, and the wait times fit inside the synchronous limit
- No duplicate source → target connections; node and edge IDs unique; no orphan nodes
Outcomes
- The decision was looked for in the body, in the URL, in the error object and in the status code before any branch was written
- Success is decided on the business result, not on the status code alone
- Every documented business response leads somewhere; the catch-all is scoped to the business response
- Technical responses are left uncovered and Dead End
- Branch conditions do not overlap unintentionally
- Every business path reaches an End node
- Every rejection End is set to Reject, not Success
{reason}mapped for every documented outcome, from the standard list, with no unconditional fallback row{reason_detail}set unconditionally from{body}Unspecifiedused where the advertiser gave no reason;Not Eligibleonly where they named a failed condition- No
{reason}derived from a status code alone
Configuration
formatset explicitly on every node sending a body- Every enum value taken from the current lists; no value the editor does not offer
- Timeouts, waits and repeats inside their limits
- No credentials or base URLs hardcoded; Basic Auth uses
{username}and{password}
Naming
- Main HTTP nodes named
Ping,PostorVerify - Start titles empty
- Store nodes named
Store response, transformation nodesTransform data, reject mappingReject reason - Wait and Breaker titles include their value
Rule set for AI agents
Deterministic rules for generating or reviewing a blueprint. Each carries an identifier so a finding can be named. Rules marked [M] are checkable against the blueprint JSON alone; rules marked [J] need judgement about what the advertiser meant, and are the review.
Node types, operators and modifications are defined by the schema and by the pages linked above, so this list does not repeat them. An agent MUST NOT emit a node type, operator or modification that the schema does not define.
Fields
- F-01 [M] MUST write a mapped value into the original form field. MUST NOT create a
*_sub,*_extra,*_newor similarly suffixed target field. - F-02 [J] MAY create a helper field only when the original value is still needed unchanged later in the same flow, or when one source feeds two differently transformed targets. It MUST use the
custom_prefix and MUST be named for its content. - F-03 [J] MUST NOT add a transformation whose purpose is to correct a malformed value. Format validation belongs to the structure.
- F-04 [M] MUST place value composition, static values and type conversions in the HTTP node. MUST NOT emit a type conversion (to_int, to_float, to_bool, to_string) as a Modify or Transform data row: form fields are strings, so the type is a property of this advertiser’s request, not of the value. MUST NOT nest conversions.
- F-05 [J] When no advertiser option matches exactly, MUST select the option that presents the lead as qualifying, except for amount, term and product, and except where it would invert an answer the person gave.
- F-06 [M] MUST use do-not-send for empty optional fields. MUST NOT send the string
"null". - F-07 [J] Where a field can only ever be invalid, MUST drop it rather than sending a repaired or placeholder value.
- F-08 [J] MUST map to a global field wherever one fits.
Transformation
- T-01 [M] MUST use at most one Transform data node per flow, placed before the first branch. A second is allowed only when it operates on data produced by an earlier step in that flow.
- T-02 [M] MUST NOT emit a Transform data or Store response node with no rows.
- T-03 [M] MUST reference
{parsedBody...},{status},{body},{headers}directly in the step that consumes them. MUST NOT copy a response value into an intermediate field that the very next node then reads. - T-04 [M] MUST place a fallback first in the section, or last with an explicit
{_N}source. MUST NOT place a fallback last with the field itself as source unless its condition is written against the form’s vocabulary and cannot be true for a value a row above already mapped. - T-05 [J] MUST include the index when addressing a single element of an array:
{parsedBody.errors.0.code}, never{parsedBody.errors.code}. Whether a container is a list or a map keyed by field name is a property of the response, not of the name, so this is read from a real response. - T-05b [M] MUST address the same container the same way throughout one blueprint. Where both an indexed and a keyed form appear, one of them resolves to literal text.
- T-06 [C] SHOULD target a regex operator at the narrowest reference that answers the condition. Object and array references resolve to their JSON text and are valid targets, but the wider the target the more places a pattern can match by accident.
- T-07 [M] MUST write the comparison value into
valuein a Modify row and intooutputon a connection. MUST leave it empty foralways,is_empty,is_not_empty,is_trueandis_false. - T-08 [M] MUST write the search pattern into
patternand the replacement intooutputforregex_replace. MUST leavepatternempty for every other modification. - T-09 [M] MUST leave
outputempty for the modifications that take no argument, and MUST fill it for the ones that do. - T-10 [M] MUST write references in braces, with no whitespace inside them. A reference without braces is a literal string that will never match.
Graph
- G-01 [J] MUST express decisions as conditions on connections. MUST NOT create a node solely to enable a later condition.
- G-02 [M] MUST have at most one connection per source → target pair; multiple conditions on one connection are AND-ed.
- G-03 [J] MUST make alternative branches mutually exclusive, and MUST include a catch-all
Regex not matchbranch against the known business values. - G-04 [M] MUST NOT build a catch-all on the status code alone. MUST scope it to a body field, or combine it with the success status code.
- G-05 [J] MUST NOT derive
{reason}from a status code alone. MAY branch on a 4xx or non-standard status when the advertiser returns a documented business result in the body. - G-06 [M] MUST NOT duplicate Auth within a flow while the credential remains valid, and MUST NOT connect flows.
- G-07 [M] MUST include a Breaker in every loop, with the count in its title.
- G-08 [M] Node IDs and edge IDs MUST be unique; no orphan nodes; every business path MUST reach an End node.
- G-09 [M] Node IDs MUST be
{flow}-{position}and consistent with the actual graph. - G-10 [M] MUST NOT emit a node type that the backend does not implement.
Outcomes
- O-01 [J] MUST base the success branch on the advertiser’s business result, alone or combined with the status code, never on the status code alone.
- O-02 [M] Every End node on a rejection path MUST carry the literal
failed. Any other value, including a typo, is read as an accepted lead. - O-03a [J] MUST map
{reason}from the standard list for every documented rejection outcome. - O-03b [M] MUST NOT add an unconditional row to
{reason}. - O-04 [M] MUST set
{reason_detail}from{body}on an unconditional row. Another field MAY be added as a further conditional row on top. - O-05 [J] MUST use
Unspecifiedwhen the advertiser’s documented answer gives no reason. MUST useNot Eligibleonly when they name a specific failed condition. - O-06 [M] MUST collect all reason mappings into a single
Reject reasonnode per rejecting HTTP stage. - O-07 [J] MUST NOT map a technical failure to a reject reason. Leave it uncovered.
Schema and limits
These are not style. Each one either fails validation or is silently altered on save.
- S-01 [M] A Modify row MUST have a modification. A row with an operator and no modification fails settings validation, and a step whose settings fail does not run at all: not the row, the whole node.
- S-02 [M] Operators and modifications MUST come from the current snake_case enums. Hyphenated names pass the editor’s own check and are then skipped at run time with nothing logged.
- S-03 [M]
format,method,parser,notsendandconvert_dataMUST hold values the editor offers. A Breaker count that fails its pattern is silently replaced with1. - S-04 [M] Limits: HTTP timeout at most 295 seconds, Repeater at most 20, Wait at most one year, 50 steps per scenario. Two more are runtime rather than structural: 30 runs of one step per scenario run, and the synchronous delay ceiling before a run goes asynchronous.
- S-05 [M] A Set key and a Modify target MUST be written in braces.
- S-06 [C] A row reference MUST be written as
{_1}, which is what the editor inserts. Row indexes are renumbered on save after empty rows are dropped. - P-01 [M] Parameter names MUST be snake_case.
Configuration
- C-01 [M] MUST set
formatexplicitly on every node sending a body. It is not inherited. - C-02 [M] Base URLs MUST live in integration keys. Basic Auth MUST use
{username}and{password}, encoded at run time. Credentials MUST NOT be hardcoded and a pre-encoded blob MUST NOT be stored. - C-03 [M] A runtime token MUST go into a
custom_parameter and MUST NOT be written back into the secrets table. - C-04 [M] Secret names MUST come from the allowed list.
custom_*is the only free space. - C-05 [M] A body format that needs nesting MUST be written as
data.fieldkeys; the editor produces a flat object otherwise and the request throws.
Naming
- N-01 [M] Every flow MUST contain one HTTP node named for the flow:
Ping,PostorVerify. Start titles MUST be empty. - N-02 [M] Response Set nodes MUST be named
Store response; transformation nodesTransform data; reject mappingReject reason, qualified only when a flow has several rejecting stages. - N-03 [M] Wait and Breaker titles MUST contain the configured value.
- N-04 [C] SHOULD use
1-*for Post flow IDs and2-*for Ping. MUST NOT report a deviation as a finding.
Review output
When reviewing, report findings by severity, naming the rule and the node:
- Critical, the integration is broken or misreports outcomes: O-02, T-10, C-01, C-02, C-05, G-08, G-10, S-01, S-02, P-01.
- High, wrong data is sent or rejections are mislabelled: F-01, F-06, T-04, T-05b, T-07, T-08, T-09, G-04, G-07, G-09, O-03b, O-04, O-05, O-07, C-03, C-04, S-03, S-04, S-05.
- Medium, duplication and structure: F-04, T-01, T-02, T-03, G-01, G-02, G-06, O-06.
- Convention, naming and scope: N-01 to N-04, T-05, T-06, S-06.
MUST NOT reorder a flow to fix a naming issue, and MUST NOT report a flow-id convention as a finding.
What a checker cannot decide
Thirty-six of these rules are checkable against the blueprint JSON alone. The twelve marked [J] all turn on the same question: what did the advertiser actually mean. No schema tells you whether a one-word refusal names a condition or merely says no, whether a regex repairing a postcode belongs here or in the structure, or whether a container is a list or a map.
Those are the review, and they are where attention is worth spending. Everything above them should already have been caught before a person opens the blueprint at all.

