Skip to content

27. An External Integration

Real systems don’t run in isolation. They call payment processors, talk to identity providers, accept webhooks from analytics tools, hand off shipping requests to carriers. The architectural decisions at the boundary — which calls cross your perimeter, what comes back, what to model and what to leave out — are different from anything covered in the previous two chapters.

This chapter models one such integration end to end: a payments service that integrates with Stripe (a payment processor) for outbound charges and accepts webhooks from Stripe for status updates. Compact, but every concern is real.

What we’re modeling

Customer ──► Payments.Authorize
Payments ──► Stripe.Charge (outbound, sync HTTP)
Stripe ──► Payments.PaymentWebhook (inbound, async)
Payments ──► Ledger.Record

Three Archlang modules: Payments (yours), Stripe (external), Ledger (yours). Two boundaries — outbound from Payments to Stripe, inbound from Stripe to Payments via a webhook.

Why this is a separate worked design

Two patterns matter here that didn’t come up cleanly in Chapters 24-25:

  • The external_system kind, which forces two required fields (ext.vendor, ext.contract.url) so external dependencies always document what they are and where their contract lives.
  • Webhooks as inbound interfaces on your module, with the external system as the caller. Newcomers often try to put the webhook handler on the external side; that’s wrong — the external system delivers the call; your handler receives it.

If you get those two right, integration modeling is straightforward. The rest of this chapter is the example.

Package layout

shop-payments/
├── package.archspace
├── payments.arch # Payments, Ledger
├── integrations.arch # Stripe (external_system)
├── processes.arch
└── views.arch

Manifest:

name: acme.payments
version: "0.1.0"
use * from arch.modules
use * from arch.kinds

The modules

payments.arch:

service Payments {
team: Payments
labels {
domain: Payments
security.zone: PCI
}
"Card payment processing. Authorizes via Stripe; receives webhooks for async outcomes."
command Authorize {
"Customer-facing authorize. Calls Stripe synchronously, returns a hold token."
}
command Capture {
"Capture a previously authorized hold."
}
command Refund {
"Refund a captured payment."
}
command PaymentWebhook {
"Inbound webhook from Stripe — payment_succeeded, payment_failed, refund.created.
Verified by HMAC signature on the request."
subscribes: Stripe.PaymentEvents
}
}
service Ledger {
team: Finance
labels {
domain: Finance
security.zone: Internal
}
"Immutable financial record."
command Record
}

The crucial bit: command PaymentWebhook is a normal command on Payments, with subscribes: Stripe.PaymentEvents. From the model’s perspective, Stripe publishes events; Payments handles them. The fact that those events come in over an HTTP POST that Stripe sends is implementation detail.

integrations.arch:

external_system Stripe {
team: External
ext.vendor: "Stripe"
ext.contract.url: "https://stripe.com/docs/api"
labels {
domain: Payments
security.zone: External
}
"External card processor. Outbound HTTP calls + inbound webhooks."
command Charge {
"Outbound. Authorize/capture/refund routed here."
}
event PaymentEvents {
"Outbound (from Stripe's perspective) webhook stream.
Includes payment_succeeded, payment_failed, refund.created."
}
}

Three things to notice:

  1. ext.vendor and ext.contract.url are required. They come from the stdlib external_system type. The team can’t get away with adding an external system without saying what it is and where its docs live.
  2. Stripe.Charge is a normal command. From the architecture’s perspective it’s a synchronous interface — the call shape — even though the team isn’t the one running it.
  3. Stripe.PaymentEvents is an event. This is what your inbound webhook handlers subscribe to.

Mistake to avoid. Don’t model the webhook receiver as something on Stripe (“Stripe.WebhookSender” or similar). The webhook receiver is yours — it’s an interface on your module. Stripe’s side is just an event source. The asymmetry is real: Stripe doesn’t model you in their architecture either.

The processes

processes.arch:

process #flow01 OutboundCharge {
Customer > Payments.Authorize
Payments > Stripe.Charge : "synchronous HTTP POST"
try {
Payments > Ledger.Record : "log the hold"
} catch "declined" {
Payments > Ledger.Record : "log the decline"
}
}
process #flow02 InboundWebhook {
Stripe > Payments.PaymentWebhook : "HMAC-verified async delivery"
Payments > Ledger.Record
}

OutboundCharge captures the synchronous side — Customer initiates, Payments calls Stripe, Stripe responds, the result lands in Ledger. The try/catch makes the decline path explicit.

InboundWebhook is the asymmetric flow. Stripe is the caller; the call lands on our PaymentWebhook handler. Following the principle from Chapter 7: the caller is the entity making the call, which is Stripe. The interface lives on the receiver, Payments.

Views

views.arch:

view PaymentBoundary {
focus domain: Payments
group by team
layout elk
"Both sides of the Stripe boundary, with the inbound and outbound flows."
}
view ExternalSurface {
focus security.zone: External
layout dagre
"Every external system we depend on. For vendor-risk review."
}

ExternalSurface is the view a vendor-risk reviewer wants. The security.zone: External label is set on Stripe; the view picks up that and any other external system in the workspace.

What this gives you

After validation:

  • A diagram with three modules — your two, Stripe in a visibly distinct treatment (external systems get their own widget by default).
  • Two process flows visible: the outbound sync charge and the inbound webhook.
  • Subscription wiring on PaymentWebhook so the renderer joins it to Stripe.PaymentEvents with a dashed edge.
  • A “vendor surface” view automatically populated when more external systems get added.

Patterns for richer integrations

This example has one external system, one outbound interface, one event. Real integrations vary:

Multiple operations per vendor. Stripe has 50+ API endpoints. Model the ones your architecture cares about — usually 3-8. The rest don’t need to appear; they’re vendor implementation detail.

Webhook routing through one endpoint. Many systems land all webhooks on a single URL and dispatch internally. That’s still modeled as multiple interfaces — the dispatch is implementation. From the model’s view, there are N webhook flows.

Vendor-replaceable systems. Plaid, Stripe, Adyen — you might support multiple providers. Use a project-defined type kind like card_processor that pins the contract; instances slot vendors into it. Subject of Chapter 29.

Identity providers. Auth0, Okta, internal SSO. Same pattern as Stripe: external_system with command Login, event UserEvents for webhook events. The shape doesn’t change with the vendor.

Async-only integrations. Some vendors only push (webhooks, no callable API). Just declare the event interface and the receiver; skip the outbound command. The model handles it fine.

Boundary discipline

The single rule worth internalizing:

Calls go into your perimeter; you receive them. Calls leave your perimeter; you originate them. Always model who calls; the receiver is always the thing whose interface is invoked.

That sentence is the whole chapter. If you can answer “who calls?” you can model any integration. The grammar (Caller > Callee.Interface) and the external_system kind do the rest.

Summary

  • Use external_system for things outside your operational boundary; required ext.vendor and ext.contract.url keep documentation honest.
  • Webhook receivers are interfaces on your module; the external system is the caller.
  • Subscribe wiring (subscribes: External.Events) connects your handler to their event stream.
  • Model 3-8 interfaces per vendor — the ones your architecture talks to, not the vendor’s full API.
  • Vendor-risk views fall out of focus security.zone: External once external systems carry that label.

What’s next

Chapter 28: Compliance Boundaries → — labels, views, and validation rules working together as governance.