ZuloOne
English
Sign in
Menu

📤 Outbound integrations

Updated

How the business layer talks to an external system without receiving network access or secrets. Short recipe: Send an outbound call.

← Developer wiki

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.json in 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.
Was this page helpful?