📤 Outbound integrations
Updated
How the business layer talks to an external system without receiving network access or secrets. Short recipe: Send an outbound call.
Two forms, and the choice is not about style#
IOutboundGateway |
IOutboundCall |
|
|---|---|---|
| What it is | a queue (outbox) | a synchronous call |
| When | a document has to be sent out | an exchange needs its response now |
| Retry | yes, with an exponential backoff and a log of attempts | no |
| Idempotency | a unique index on (channel, key) |
the key only reaches the signature |
| The response | arrives later and is collected separately | returned to the call |
The rule is simple: if losing the message means losing the document, use the queue. An invoice sent to the tax authority must stay in the system until it is confirmed, no matter how often the network fails.
When the response is single-use, the queue is harmful. ZATCA onboarding
returns a secret that is valid once: an outbox row would log it, and a retry an
hour later would present an already-used OTP. That is why IOutboundCall is a
separate API.
// a document — into the queue
await ScriptServices.Get<IOutboundGateway>().EnqueueAsync(new OutboundRequest
{
Channel = "fatoora-clearance",
IdempotencyKey = "zatca:" + uuid.ToString("N"),
Payload = body,
SourceTable = "TaxDocument",
SourceRecordId = envelope.MetaId,
});
// an exchange — straight away
var result = await ScriptServices.Get<IOutboundCall>().SendAsync(
"fatoora-compliance", payload, idempotencyKey: "zatca-compliance",
headers: new Dictionary<string, string> { ["OTP"] = otp });
if (result.Ok) { /* result.Body */ }
Both forms take a channel name, not a URL. This is not a convenience; it is
a boundary. The host resolves the address, credential, signature and SSRF
check. HttpClient is outside the scripts' reference set, and
ICredentialResolver is on the deny-list.
A channel is an object of a model#
OutboundChannels/<name>.channel.json, kind OutboundChannel:
{
"kind": "OutboundChannel",
"object": {
"path": "/invoices/clearance/single",
"method": "POST",
"signer": "fatoora",
"authMode": "Signer",
"contentType": "application/json",
"baseUrl": "https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal",
"connectionTable": "TaxAuthorityConnection",
"devComments": "B2B: ZATCA clears the invoice BEFORE it reaches the buyer.",
"metaId": "…", "name": "fatoora-clearance", "modelId": "…"
}
}
A channel is code-shaped: the path, verb and signing profile change when
the integration changes, not when the tenant changes. "Code-shaped and shipped
with the release" is the definition of a model object, so a channel belongs in
the model, not in the platform's appsettings.
The tenant half lives separately: this customer's base address, credential
and timeout are stored in the connection business record, which
OutboundConnectionResolver reads. The channel names the dictionary in the
connectionTable field (TaxAuthorityConnection for Fatoora and ЄРПН,
UaBankConnection for pb-statements). An empty field falls back to
TaxAuthorityConnection. That default is for tax channels, not an invitation
to put a bank token on a tax card. Do not conflate the channel and the
connection: they are two objects, not a third mechanism.
A per-document query (charCode, edrpou, exDocId on M.E.Doc MakeDoc) does
not fit in the channel's path. The script puts the relative path
in the X-ZuloOne-RelativePath header. The host strips it off, appends it to
the connection's BaseUrl, and does not put it on the wire. An absolute URL in
that header is refused: the host still comes only from the card.
authMode: Login — before the channel's own request the host issues
POST /api/System/v2/login and sets X-API-Key. The password comes from
Integration:Credentials, the login from CredentialRef. The script does none
of this: it has neither the secret nor HttpClient. If there is no password, the login
is skipped. On the stand, IntegrationStubController imitates the contract:
POST /api/System/v2/login issues a key, submit-authed without it returns 401.
method: GET and HEAD are sent with no body. Attach StringContent
every time and Autoclient returns 400 for a statement pull when
Content-Length is present. The builder is OutboundHttpRequest, shared with the agent hop.
The attempt journal masks the token header alongside authorization /
x-api-key.
What to manage a channel with#
- A screen — Developer → Outbound channels
(
frontend/src/pages/outboundchannels/). - An API —
api/metadata/outbound-channels: the list, the card, create, edit, delete, plus two special ones:GET effective/{name}— the definition as the runtime sees it;POST {id}/test— Test channel: a real probe over the whole path (definition → connection → credential → signature → SSRF gate), without a row in the outbox. Header values are not exposed.
- A file —
OutboundChannels/<name>.channel.jsonin the model.
Configuration overrides metadata#
Integration:Channels:<name> overrides the model's channel. That is deliberate:
this is how a stand is redirected to a local stub and a customer to a simulation
gateway, without editing someone else's model, which they cannot reissue.
Hence a consequence that is easy to violate: the shipped appsettings.json
must not carry a single business channel — it would not be a "default value" but
a permanent override of the owning model. In appsettings.Docker.json the
fatoora-* channels are there on purpose: that is a stand file, and redirecting
them to a stub is exactly what it does.
A signing profile is an object of a model too#
Generic schemes (none, hmac-sha256) live in the platform. A profile that
encodes the request format of ONE authority does not: the four headers out of
the Fatoora manual are Saudi specifics.
A model's service implements the platform seam:
public partial class FatooraSigner : IOutboundSigner
{
public string Profile => "fatoora";
public IReadOnlyDictionary<string, string> Sign(OutboundSigningContext context)
=> new Dictionary<string, string> { ["Accept-Version"] = "V2", … };
}
OutboundSignerRegistry searches the host's own profiles first, then the
models' services (ServiceRegistry.ResolveAllImplementingAsync). There may be
many profiles and they coexist — unlike ResolveImplementingAsync, which
answers the question "who overrides the behaviour" and is obliged to complain
about two implementations in one layer.
An unknown profile throws instead of sending unsigned. A signature that quietly stops being applied is exactly the kind of breakage nobody notices.
Secrets#
A script names the credential by name; the host resolves the value from
Integration:Credentials:<name> (environment variables
Integration__Credentials__fatoora-csid). Secrets reach neither the workspace
nor git — it is a delivery repository.
Passwords entered on a connection#
A model opts its connection card into the generic credential editor through its own record layout. The platform does not infer this from a dictionary name and does not own any business-specific connection dictionary or captions. For example:
{
"version": 4,
"connectionCredentials": {
"title": { "en": "Gateway credentials" },
"userName": { "en": "Gateway login" },
"password": { "en": "Gateway password" }
}
}
Put this declaration in the model's record FormLayout JSON alongside its layout
nodes. Captions accept a string or a language map with an English fallback. The
generic defaults are Connection credentials, Login and Password.
Without the declaration, no credential editor is rendered. The customer's
Okrasheno model owns its OneCConnection.Record layout and all 1C captions.
The normal Save action saves the record first and then sends the credential to a separate administrator-only endpoint. The password is never a dictionary field or part of the normal record-save payload.
The endpoint /api/integration/connections/{table}/{recordId}/credential
supports GET, PUT and DELETE. It manages the protected credential and
requires the Admin permission, an existing record,
and a metadata channel using that connection table with Header authentication
and the Authorization header. Responses contain only userName,
hasPassword and unreadable; they never include the password or ciphertext.
PUT accepts userName and password. A null or empty password preserves the
stored password; the first save requires a password. DELETE explicitly removes
the stored login and password. Login changes and password rotation take effect
without restarting Core. Password whitespace is significant and is preserved.
ConnectionCredentialStore keeps the login and a Data Protection-encrypted
password in an Integration.ConnectionCredential.* row in MetaSystemSettings,
which is not exported by the workspace exporter. The encryption purpose includes
the connection table and record id: ciphertext copied to another connection
cannot be decrypted there. Preserve the stand's Data Protection key ring across
restarts, restores and replica changes, as for SMTP and AI credentials. A missing
key makes the password unreadable; re-enter it rather than falling back silently.
The outbound connection resolver selects the stored credential for that record
automatically and generates HTTP Basic authorization at dispatch time. An entered
CredentialRef is not required. Manually assigning the reserved connection:
reference prefix to a channel or connection is rejected. A saved record credential
takes precedence over the legacy configuration name; after explicit removal, the
legacy CredentialRef or channel default can apply again.
This does not enable a disabled connection or its scheduled job, change HTTP allow-lists, or verify external access. Probe the channel before enabling import. The credential is local to the stand database and is not shipped with a model package. Cloned records have different ids and do not inherit the password.
If a script needs a signature made with a key held by the host, use
ICredentialSigner:
var stamp = ScriptServices.Get<ICredentialSigner>().SignHash("fatoora-csid", hashBase64);
// stamp.Signature, stamp.PublicKeyDer, stamp.CertificateSignature — all public
// stamp == null when no certificate is configured: the caller must be able to degrade
A script can hash and sign on its own: System.Security.Cryptography is in
the reference set. The boundary is not cryptography; it is reading a configured
secret: see what a script can do.
How the sender runs#
OutboundSenderService is a BackgroundService of its own, not a scheduler
job: jobs run one after another on a single tick and would block each other, and
a slow endpoint would stall the stand. More importantly, it
works without an ambient transaction: a body inside a transaction may be
replayed by DeadlockRetry, and a replayed HTTP POST is a second submission of
the document.
For the same reason, the sender cannot become a script: a script is always inside the scope of a request, a job or a test.
Retries: 400/401/403/409/422 are terminal from the first attempt; 408/429/5xx
get an exponential backoff from 30 s to 1800 s with jitter, up to 8 attempts.
Two copies of the sender will not take the same row (UPDLOCK/READPAST or
FOR UPDATE SKIP LOCKED).
Operators watch the outbox on Administrator → Outbound queue: statuses,
attempt count, a card with the attempt log, retry, cancel, and purge of
completed rows. Two warnings sit at the top — sender switched off, and empty
AllowedHosts — so a queue of nothing but Pending is not mistaken for a slow
network.
When the connection (or the channel) has AgentTag filled in, the host does
not dial out itself: a live agent carrying that tag makes the HTTP call from
its own machine, including to 127.0.0.1. A loopback from Core is still SSRF.
Cloud metadata addresses are refused by the agent as well. Ukraine: MakeDoc /
ToGov / GetDocInfo ride this hop; the PrivatBank statement is dialled from the
host (acp.privatbank.ua is public). There is no live M.E.Doc and no live ACP
in CI.
The SSRF boundary#
Integration:AllowedHosts is the allow-list of hosts; an empty list is a development
stance, and it is written loudly to the log once at startup. The list must
come from the configuration and never from the database: otherwise a user with
the designer role can point the server at an internal address.
AllowedPrivateHosts holds exceptions by name, not a switch for "allow private
networks".
Seven statuses, and one of them is not about failure#
Pending → InFlight → Succeeded; on failure: Failed (a retry is
coming), Abandoned (the attempts are exhausted), Cancelled (cancelled by
hand).
InDoubt stands apart: "sent, no response". The request went out, the
connection broke before the response, and the document may be sitting at the
recipient. A retry for it is refused, and that is not excessive caution:
submitting an invoice to the tax authority twice is the very incident the queue
was built to prevent. Only a status query to the recipient can resolve the
doubt.
For the same reason a retry of Succeeded is refused: it would be a second
submission, not a retry.
Feedback: the delivery outcome onto the document#
The queue ends not with delivery, but with its OUTCOME written onto the document. There is a pair for that:
var batch = await gateway.TakeCompletedAsync(channel, 100); // completed, not yet processed
// … write the outcome onto your own documents …
await gateway.AcknowledgeAsync(ids); // only now do they go away
The order matters here: until they are acknowledged, the messages come back again. A crash in the middle of updating the documents leads to another processing pass, not to the loss of the result.
The working example is the Saudi model: the ApplyFatooraOutcomes job calls
ISaudiEInvoice.ApplyChannelOutcomesAsync once a minute, and the tax document's
subtype (Cleared / Reported / Rejected) comes from the channel, not
from a mock.
What's not there yet#
- Receiving inbound calls (a webhook) is not in the product: the host is outbound only.
Where next#
- ZATCA / Fatoora — the working example of a tax authority.
The same host serves the country package's channels: a bank
(
pb-statements), a government portal, a cabinet. They are not in Core, and there is no separate country-less "integration" model. - What a script can do — what is available and what is forbidden.
- A model's web services — the inbound side.