Outbound integrations (the outbound connector)
Updated
How the business layer sends data out — to a government system (ZATCA/Fatoora), to a fund (GOSI), to a bank — without ever being given network access.
Why this, and not something simpler#
A script cannot be given the network.
RuntimeCompiler.GetDefaultReferences does not include System.Net.Http, so
new HttpClient() in a script does not compile; on top of that, the script
security policy keeps network types on a deny-list. Adding the reference would
open the network to EVERY script of every model, and the analyzer runs
in Warn mode by default and checks only the writing of a script, not its
execution. The boundary runs where the reference puts it: the business layer
builds the payload, the platform signs it and sends it.
Sending synchronously out of posting is not allowed. Posting runs inside a
TransactionScope, and on a deadlock DeadlockRetry REPLAYS the body of the
transaction — an HTTP request inside it would be repeated. On top of that, the
transaction holds locks while the external service responds at an arbitrary
time.
Hence a transactional outbox, the same trick as the mail queue, but with that queue's weak spots closed: there is a backoff, a row lease, an attempt log and an idempotency key.
The flow#
script → IOutboundGateway.EnqueueAsync (a row in MetaOutboundMessages,
inside the business operation's transaction)
↓ the operation rolled back — there is no message
OutboundSenderService (a background service, outside transactions)
claim with a lease → resolve the address → credentials → signature → HTTPS
↓
MetaOutboundAttempts (the attempt log) + the message status
↓
business job: TakeCompletedAsync(channel) → stamp the document status →
AcknowledgeAsync
The settings split: the channel versus the connection#
| Where it lives | Who changes it | What is in it | |
|---|---|---|---|
| The channel | an object of the model (OutboundChannels/*.channel.json); the stand's config can override it |
the developer, through a model release | the path, the method, the signing profile, connectionTable |
| The connection | the dictionary the channel has named (TaxAuthorityConnection, UaBankConnection, …) |
the administrator, in the UI | the code, the legal entity, the environment, the address, the timeout, the attempts, a REFERENCE to a credential, the agent tag |
A connection is looked up by the Code field, not by ID: ID is
handed out by the dictionary's number sequence, and it is a sequential number
that cannot be named in a channel or in a script. Code is entered by the
administrator.
If a reference to a connection is set but there IS no record with that code,
the message fails with the Config class rather than "quietly taking the
channel's settings". A silent fallback would send the document to a different
address, or sign it with a different credential, and the result would read as
the recipient refusing a perfectly correct submission.
The split is deliberate: moving a legal entity from the sandbox to production must not require a release, whereas adding a new channel must.
A script never sets a URL. It names a channel and a connection; the address is assembled at the moment of sending — which is why the SSRF guard sees the final address, and a week-old message goes out to the current one.
Secrets#
Credential values live in the configuration/the environment
(Integration__Credentials__<name>) behind the ICredentialResolver
interface. The metadata holds only the NAME of the credential.
Why not in the database, and least of all in global constants:
MetaGlobalConstant is exported into the workspace, and the workspace is the
git repository of the delivery. A secret would end up there in three copies in
one file.
The value is not logged, is returned by no API at all (the admin area gets only the fact of "configured / not configured") and is not exported. The price of the decision is that rotation needs a redeploy; when that starts to get in the way, an encrypted store will appear behind that same interface, and the callers will not change.
Idempotency#
IdempotencyKey is what makes a send at most once. The guarantee comes
from the UNIQUE INDEX (Channel, IdempotencyKey), not from code: code can be
raced, an index cannot. Enqueuing again with the same key and the same content
returns the existing id; with DIFFERENT content it fails, because that is a
caller bug that would silently lose one of the two documents.
Derive the key from a business fact (the invoice id), not from the clock or
from Guid.NewGuid(): a key that changes on a retry submits the document
twice.
RequestHash is SHA-256 of the payload VERBATIM and BEFORE the signature, with
no whitespace normalization: two payloads that differ in whitespace are, to a
government authority, different documents. Before the signature, because the
signature carries a timestamp and would change on every attempt.
Error classes and retries#
| Response | Class | Retry |
|---|---|---|
| 2xx | — | success |
| 400, 422 | Validation |
no — the recipient read it and refused |
| 409 | BusinessRule |
no |
| 401 | Auth |
no — retrying with a bad credential leads to a lockout |
| 403 | Authorization |
no |
| 404, 405, 415, 501, 3xx | Config |
no |
| 408 | Timeout |
yes |
| 429, 5xx | Network |
yes, Retry-After is honoured up to a ceiling |
| a TLS/certificate error | Config |
no — it will not clear by itself, and a retry hides it |
| no response arrived, the request went out | InDoubt |
no — see below |
What is non-retryable is terminal IMMEDIATELY, not "retry until the attempts run out": re-sending a document the recipient has already rejected is pointless.
Backoff: 30s · 2^(n−1) up to 30 minutes, with ±20 % jitter.
InDoubt — the state all of this is for#
The request went out, the response did not come back. That is not the same
thing as "not sent". A blind retry here is exactly the scenario in which a
second submission goes to a government body. Such a message is not retried
automatically: it waits for a status query
(IOutboundTransport.CheckStatusAsync) or for a human operator. The row lease protects
against two SENDERS, not against a lost response — those are different
problems.
SSRF protection#
The address comes out of business metadata, which is edited by a user holding the developer role. With no restrictions, "name an address" would mean "make the server connect anywhere, the internal network and the cloud metadata service included".
OutboundUrlGuard: https only (http is for the local stub alone, behind an
explicit flag); refusal on user:pass@ in the address; an allow-list of ports;
an allow-list of hosts from the configuration, never from the database; a
deny-list of metadata addresses; resolution of every address of the host with a
refusal on loopback/link-local/private/CGNAT/ULA, IPv4-mapped IPv6 included;
redirects are not followed.
The key detail: the check is repeated in ConnectCallback — on the address the
socket is about to dial. Without that, the first check is merely advisory: DNS
can return a harmless address for the validation and an internal one for the
connection.
The single exception is AllowedPrivateHosts: a by-name list of internal
addresses that may be reached (the local stub, an on-prem gateway). It is a
LIST, not an "allow private networks" switch, because a switch thrown for the
sake of one gateway would open up the metadata service as well, and loopback
back into this very API.
The second route is OutboundReach.Agent. The same 127.0.0.1 is
SSRF from Core; from the agent on the accountant's PC, it is the whole point of
the hop
(M.E.Doc). The agent skips the host and port allow-lists and permits
loopback/RFC1918; it rejects 169.254.169.254 and user-info exactly as the
host does. The route is chosen by AgentTag on the connection record, not on
the channel. On the stand the hop is not alive until Core and Agent have been
rebuilt.
What has been verified, and what has not#
Verified on the stand (the api/dev/integration-stub stub, dev mode only):
successful delivery with the external id written end to end; a retry on
5xx with a growing delay; 4xx being terminal with no retries; attempts running
out → Abandoned; idempotency (the same key → the same id; a different payload
→ refusal); a missing credential → Config with not a single network request
and with no leak of the value; SSRF (the metadata service, loopback,
user:pass@) and not following a redirect; signature verification — the
stub recomputes the HMAC and returns 401 on a mismatch, class Auth, no retry;
masking of Authorization in the attempt log; the lease — several parallel
claimants, not one row twice; enqueuing inside a live TransactionScope does
not trip MSDTC, and rolling the operation back leaves no message behind.
NOT verified:
- PostgreSQL. The stand runs on SQL Server, so the
FOR UPDATE SKIP LOCKEDclaim branch is written but has not run yet. Until it has been run on Postgres, treat it as unverified. - The real ZATCA. Neither a certificate nor sandbox access; the endpoints,
authentication scheme and the response format are marked
NEEDS_VERIFICATIONin the reference itself. Not done: UBL 2.1, QR-TLV, the PIH chain and the ECDSA/CSID signature. The transport for them is ready — what is missing is the signing profile and a real API contract. - A timeout and
InDoubton a live request. The state and the code branch exist; the lost-response scenario has never been reproduced on the stand.
What's not there yet#
- The operator page (the front end). The API is already there —
/api/integration/outbound: a list with filters, a message with its payload and attempt log,retry,cancel,purge,status. It requires the Admin right rather than plain authentication, as the mail queue does: the payload holds a tax or commercial document and a government body's response. The list does NOT hand out the payload or the body of the response — only the message card does.retryrefuses anything already delivered (that would be a second submission, not a retry) and anythingInDoubt(the recipient may have accepted it; find out its status first). Retention:purgedeletes only COMPLETED messages older than N days. - A retention policy and cleanup of the queue.
CheckStatusAsyncfor a channel that can do it, so thatInDoubtresolves automatically.- An SFTP transport for bank files (the abstraction is already laid in).
- Moving the
ScriptSecuritymode toEnforcebefore third-party models are accepted: now that the gateway exists, "what would a script reach the network with" has become a question of policy rather than of a missing reference.