1. Sokin MCP
Sokin Embedded API
  • Getting Started
    • Sokin Definitions
    • Guides
      • Authentication
      • Receiving Payments
      • FX Payments
      • Payments
      • Unfunded Payments
      • Unfunded FX Payments
      • Webhooks
      • Corporate Onboarding
        • Data Requirements
        • Step 1: Create a corporate profile
        • Step 2: Add associates
        • Step 3: Add a parent entity (if applicable)
        • Step 4: Upload company documents
        • Step 5: Upload associate documents
        • Real-time Status Updates via Webhook
        • Conditional Step: Upload Parent Entity documents (if applicable)
        • Step 6: Finalise onboarding submission
        • Uploading supporting documents using Pre-signed URLs
        • Onboarding Reference Data
        • Onboarding Models
          • Data-Only model
          • Supported Jurisdictions & National ID Requirements
      • Sokin MCP
        • Sokin MCP - Setup and Usage Guide
        • Sokin MCP - Tool Guide
        • Sokin MCP - Technical Docs
  • Authentication
    • Get Token
      POST
    • Schemas
      • TokenResponse
  • Corporates
    • v2025-12-01
      • Create a new Corporate
      • Add a parent entity (corporate associate) to an onboarding request
      • Add an associate to a corporate onboarding request
      • Request pre-signed URLs for onboarding document uploads
      • Request pre-signed URLs for parent entity document uploads
      • Request pre-signed URLs for associate document uploads
      • Finalise corporate onboarding submission
    • Schemas
      • CreateCorporateRequest
      • UboDetails
      • OwnershipType
      • AssociateDocumentPresignedUrlResponse
      • AddOnboardingDocumentsRequest
      • MessageResponse
      • AssociateDocumentPresignedUrlResponseItem
      • ErrorDetail
      • CorporateAssociateType
      • ErrorResponse
      • PresignedUrlResponse
      • AddressTypeEnum
      • FinaliseSubmissionRequest
      • AddParentEntityRequest
      • CreateCorporateResponse
      • AssociateIdentityType
      • ApiResponse[AddOnboardingDocumentsResponse]
      • CorporateCategories
      • AddOnboardingDocumentsResponse
      • ApiResponse[CorporateDetailResponse]
      • ApiResponse[AddAssociateDocumentsResponse]
      • AddParentEntityDocumentsRequest
      • AddAssociateDocumentsResponse
      • AddParentEntityResponse
      • AddIndividualAssociateRequest
      • ApiResponse[CreateCorporateResponse]
      • AddIndividualAssociateResponse
      • CorporateDetailResponse
      • AssociateType
      • ApiResponse[AddParentEntityResponse]
      • NumericRange
      • ApiResponse[AddIndividualAssociateResponse]
      • MoneyRange
      • AddAssociateDocumentsRequest
      • FinaliseSubmissionResponse
      • AssociateIdentityType
  • Corporate Currency Accounts
    • v2025-12-01
      • Get Corporate Currency Accounts
      • Get Cca Ledger Items
      • Get Corporate Currency Account By Reference
    • Schemas
      • GetCCAByReferenceResponse
      • GetCCAsResponseItem
      • PaginatedApiResponse[GetCCALedgerItemsResponseItem]
      • ErrorDetail
      • PageInfo
      • SearchCCALedgersResponseItem
      • PaginationInfo
      • GetCCAUnifiedBalanceResponse
      • PaginatedApiResponse[SearchCCALedgersResponseItem]
      • ErrorResponse
      • ApiResponse[GetCCAUnifiedBalanceResponse]
      • SearchCCALedgersRequest
      • PaginatedApiResponse[GetCCAsResponseItem]
      • ApiResponse[GetCCAByReferenceResponse]
      • PaginationInfo
      • SearchCCALedgersPagination
      • GetCCAByReferenceResponsePayInDetail
      • CurrencyCode
      • GetCCALedgerItemsResponseItem
      • PaginationInfo
  • Beneficiaries
    • v2025-12-01
      • Get Beneficiary Schema
      • Create Beneficiary
      • Get Beneficiary List
      • Get Beneficiary Details
      • Delete Beneficiary
      • Validate Beneficiary
      • Validate Beneficiaries
      • Acknowledge Validation Mismatch
      • List Financial Institutions
      • List Financial Institution Branches
    • Schemas
      • AcknowledgeValidationMismatchRequest
      • ValidationType
      • ProviderBankIdentifierRequest
      • ErrorDetail
      • FinancialInstitutionReferenceResponse
      • AcknowledgeValidationMismatchResponse
      • ApiResponse[AcknowledgeValidationMismatchResponse]
      • ApiResponse[list[FinancialInstitutionReferenceResponse]]
      • AcknowledgedValidationMismatchDestinationResponse
      • DeleteBeneficiaryData
      • AddressResponse
      • BeneficiarySchemaFieldCondition
      • StateOrProvince
      • ApiResponse[list[BankCountryCurrencyResponse]]
      • PageInfo
      • BeneficiarySchemaField
      • BeneficiaryValidationResultResponse
      • StateOrProvince
      • ApiResponse[BeneficiaryResponse]
      • BeneficiaryField
      • RegionalHints
      • BeneficiaryStatus
      • DestinationResponse
      • ValidationStatus
      • BeneficiaryType
      • PaymentMethodType
      • CreateBeneficiaryRequest
      • PaginatedApiResponse[FinancialInstitutionBranchReferenceResponse]
      • ValidateBeneficiariesRequest
      • ApiResponse[ValidateBatchBeneficiariesResponse]
      • CompanyDetailsResponse
      • SchemaFieldVisibility
      • FieldErrorResponse
      • RoutingScheme
      • StateOrProvince
      • ValidateBeneficiaryRequest
      • ErrorResponse
      • FieldType
      • VirtualAccountDestinationRequest
      • ApiResponse[DeleteBeneficiaryData]
      • SokinInternalDestinationRequest
      • GetBeneficiaryFieldsResponse
      • BeneficiaryResponse
      • ApiResponse[ValidateBeneficiaryResponse]
      • FinancialInstitutionBranchReferenceResponse
      • IdentifierType
      • IndividualDetailsRequest
      • SchemaFieldType
      • CurrencyCode
      • TransactionTypeEnum
      • CryptoDestinationRequest
      • AccountType
      • BeneficiaryValidationPayload
      • ApiResponse[GetBeneficiaryFieldsResponse]
      • AccountNumberType
      • PatchBeneficiaryRequest
      • BankAccountCategory
      • ApiResponse[GetBeneficiarySchemaResponse]
      • PaginatedApiResponse[BeneficiaryResponse]
      • ApiResponse[list[BankCountryResponse]]
      • AddressRequest
      • PaginationInfo
      • BankCountryCurrencyResponse
      • PaginationInfo
      • ValidateBatchBeneficiariesResponse
      • PaymentRail
      • CreateExternalBeneficiaryData
      • FinancialInstitutionRequest
      • ApiResponse[CreateExternalBeneficiaryData]
      • CompanyDetailsRequest
      • BankCountryResponse
      • CreateExternalBeneficiaryRequest
      • BankAccountDestinationRequest
      • IndividualDetailsResponse
      • BeneficiarySchemaCondition
      • BeneficiaryDestinationValidationResult
      • BeneficiaryEntityType
      • GetBeneficiarySchemaResponse
      • ETransferDestinationRequest
      • ValidateBeneficiaryResponse
      • RoutingCodeRequest
      • ApiResponse[BeneficiaryDetails]
      • BeneficiaryListItem
      • PaginatedApiResponse[BeneficiaryListItem]
      • PaginationInfo
      • PaginatedBeneficiaryFieldsResponse
      • BeneficiaryDetails
      • ApiResponse[ListFinancialInstitutionsResponse]
      • ListFinancialInstitutionsResponse
  • Instruction Requests
    • v2025-12-01
      • Create Payment Instruction Request
      • Create Fx Instruction Request
      • Create Fx Payment Instruction Request
      • Create Unfunded Payment Instruction Request
      • Create Unfunded Fx Payment Instruction Request
      • Get Instruction Request By Reference
    • Schemas
      • ApiResponse[GetInstructionRequestResponse]
      • ValidateInstructionCreationRequest
      • ErrorDetail
      • InstructionType
      • CreateInstructionRequestResponse
      • ApiResponse[CreateInstructionRequestResponse]
      • ValidateInstructionCreationResponse
      • CreateFxPaymentInstructionRequest
      • FeeBreakdownResponse
      • ErrorResponse
      • CreateUnfundedPaymentInstructionRequest
      • CreateUnfundedFxPaymentInstructionRequest
      • ApiResponse[list[str]]
      • GetInstructionRequestResponse
      • PaymentPurpose
      • CreatePaymentInstructionRequest
      • CreateFXInstructionRequest
      • ApiResponse[ValidateInstructionCreationResponse]
      • CreateSokinDirectInstructionRequest
  • Instructions
    • v2025-12-01
      • Get Instruction By Reference
    • Schemas
      • GetInstructionResponse
      • PaginatedApiResponse[InstructionListResponseItem]
      • ApiResponse[GetInstructionResponse]
      • PaginationInfo
      • ErrorResponse
      • ErrorDetail
      • PageInfo
      • InstructionListResponseItem
      • PaginationInfo
  • Foreign Exchange
    • v2025-12-01
      • Get Fx Rate
    • v2026-08-01
      • Request FX Quote
      • Get FX Quote
    • Schemas
      • ApiResponse[FxRateData]
      • FixedSide
      • FxRateRequest
      • FXValidityPeriod
      • FxRateData
      • ApiResponse[GetQuoteResponse]
      • ErrorResponse
      • ErrorDetail
      • GetQuoteResponse
      • FXTenorType
      • FxQuoteStatus
      • ApiResponse[CreateQuoteResponse]
      • CurrencyCode
      • RequestFxQuoteRequest
      • CreateQuoteResponse
  • Webhooks
    • v2025-12-01
      • Create Subscription
      • Update Subscription
      • Get Subscription By Id
      • Delete Subscription
      • Update Subscription Status
      • List Notification Logs
      • Retry Notification
    • Schemas
      • ApiResponse[UpdateSubscriptionResponse]
      • PaginationInfo
      • SubscriptionCreateOrUpdate
      • NotificationStatus
      • NotificationAttemptResponse
      • SubscribableWebhookEventType
      • SubscriptionStatusUpdate
      • ApiResponse[CreateSubscriptionResponse]
      • UpdateSubscriptionResponse
      • PaginatedApiResponse[NotificationLogResponse]
      • PaginationInfo
      • ErrorResponse
      • ErrorDetail
      • PageInfo
      • CreateSubscriptionResponse
      • NotificationLogResponse
      • WebhookSubscription
  • Payment Acceptance
    • Payment Acceptance
  1. Sokin MCP

Sokin MCP - Technical Docs

The Sokin MCP server exposes the Sokin B2B API to AI agents over the Model
Context Protocol. This page is the reference catalogue: every tool, its
parameters, and the exact shape of what it returns.
It is not an install guide. For connecting a specific client, signing in, and
reaching a first successful call, see the companion page, Sokin MCP —
Setup and Usage Guide
.

Server#

EnvironmentEndpointBest for
UAThttps://mcp.uat.sokin.com/mcpBuilding and testing an integration. This is the environment this reference documents.
Productionhttps://mcp.sokin.com/mcpLive production data and real corporate accounts. Arranged through your Sokin contact.
Both environments share the same protocol, authentication model and response
contracts:
FieldValue
TransportStreamable HTTP
ProtocolJSON-RPC 2.0 (MCP)
AuthenticationOAuth 2.1 + PKCE, with Dynamic Client Registration
Tools26
Session modelStateless: every request is independent
The tool catalogue below reflects UAT, read directly from the tools
registered in this repository. Production runs its own deployed image and
may expose an older or smaller tool set; confirm availability with your
Sokin contact before depending on a specific tool in production.
Three transport details that surprise people:
No handshake is required. The server is stateless, so a tools/call
works with no prior initialize round trip.
Responses arrive as SSE. Even a single, non-streaming call comes back
as a text/event-stream with the result in a data: event, not as a
plain JSON body.
Your Accept header must include both content types. Send
Accept: application/json, text/event-stream. Omitting the second value
is the single most common cause of a client failing to parse a valid
response.

Authentication#

The server is itself the OAuth Authorization Server your client talks to. It
brokers to Auth0 underneath, and that indirection is the point: requesting
the Sokin B2B API audience requires a parameter an MCP client has no way to
set itself. The server sets it on your behalf, so a client never needs (and
cannot supply) a Sokin API audience of its own.
Discovery follows the standard well-known paths:
EndpointPurpose
/.well-known/oauth-authorization-serverAdvertises the server you are talking to as the issuer, plus its authorize and register endpoints
/.well-known/oauth-protected-resourcePoints back at that same server as the authorization server for this resource
Both are served from whichever environment's host you are using: the server
advertises its own public base URL, so discovery works the same way on UAT
and production without either being hardcoded.
Dynamic Client Registration is enabled at /register, so clients
self-register rather than needing credentials issued by hand.
PKCE is required.
Scopes are openid, profile, email.
There is no offline_access yet, so there is no refresh token. A
session lasts the Auth0 access token's own lifetime, after which the user
re-authenticates. (The provider's code already has an MCP-level refresh-token
path wired up for when this changes, but it only ever activates if Auth0
hands back its own refresh token, which does not happen without requesting
offline_access -- so today it never fires.)
You cannot set the API audience yourself, and you do not need to.
Requests to /mcp are additionally gated by Host header checks (DNS
rebinding protection). A request addressed to a host other than the
advertised public one is rejected with 421, not 401. If you see a 421,
check the Host header before looking at your token.

Anatomy of a result#

Every tool call returns a CallToolResult with three parts:
PartTypeWhat it carries
contentarray of text blocksThe model-facing rendering: a sentence or a markdown table. Always present, and always compact in every mode.
structuredContentobject, or absentThe machine-readable payload. This is the half the full-response opt-in widens. See the caveats below; it is not present on every result.
isErrorbooleanWhether the call failed.
On a successful call both channels arrive together, and that duplication is
deliberate: content is what a model reads cheaply, structuredContent is
what your own code parses.
structuredContent is absent in two cases, and code that parses results
must handle both:
Every error result. A failure carries content and isError: true and
nothing else. Check isError before reaching into structuredContent, or
the first upstream failure your integration meets becomes a KeyError
rather than a handled error.
play_cheetah_dash, which returns a bare string and never goes
through the compact/full machinery at all.
Every other tool returns structuredContent on success.

A stray message field#

Upstream envelopes also carry a message field, filled by the B2B API's own
response helpers with message or "Success.". It is almost always that
literal string, describes the HTTP exchange rather than your result, and is
never worth reading.
Some tools strip it before building structuredContent; some pass it
through unchanged. Tolerate it rather than assume a fixed key set:
BehaviourTools
Strippedlist_corporates, list_beneficiaries, get_beneficiary, get_beneficiary_schema, validate_beneficiary, create_beneficiary, update_beneficiary, list_financial_institution_branches, validate_payment_instruction, settle_fx_quote
Passed throughlist_accounts, get_account, get_account_ledger, list_instructions, get_instruction, get_instruction_request, list_financial_institutions
Not applicable, no upstream envelopeget_fx_quote, create_fx_quote, create_payment_instruction, create_fx_payment_instruction, get_documentation_links, visualize, play_cheetah_dash
Both, depending on the branch takensubmit_payment_instruction, submit_fx_payment_instruction: stripped on a fresh submission, passed through on a replay that reports an existing payment's state
Where a tool strips it, opting into the full response brings it back. That
is noted per tool below wherever it is the only difference the opt-in makes.

Response modes: compact and full#

By default structuredContent is compact: trimmed to the fields a
caller can actually act on, with internal bookkeeping removed. A client can
opt into the full raw B2B API payload instead.
Two independent triggers, combined with OR semantics. Use whichever your
framework lets you reach.
Trigger 1: the MCP _meta side channel.
{
  "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": {
    "name": "list_corporates",
    "arguments": {},
    "_meta": { "sokin.com/full-response": true }
  }
}
The check is a strict identity comparison against boolean true. A
stringified "true" is ignored. This fails closed on purpose, so a proxy
that coerces JSON types cannot silently opt you into the wider payload.
Trigger 2: an HTTP header.
Case-insensitive and whitespace-trimmed, but the value must be exactly
true; any other non-empty value is ignored. This path exists because
several agent frameworks (LangChain/LangGraph, CrewAI, Semantic Kernel) let
application code set custom HTTP headers on a request, but expose no way to
set _meta on an individual call, even though the MCP SDK beneath them
already supports it.
Neither trigger is a tool parameter, so the calling model can never opt
itself in; only your client code can.
Here is the same list_corporates call's structuredContent, compact by
default and widened when a client opts in (real values from this server's
own test suite):
Compact (default):
{
  "data": {
    "items": [
      {
        "corporateReference": "CORP-1",
        "corporateName": "Acme Ltd",
        "assignmentType": "direct"
      }
    ]
  }
}
Full (opted in via either trigger above):
{
  "data": {
    "items": [
      {
        "corporateReference": "CORP-1",
        "corporateName": "Acme Ltd",
        "assignmentType": "direct",
        "activeFrom": "2026-01-01T00:00:00Z",
        "features": { "paymentAcceptance": true }
      }
    ]
  }
}
content is identical either way: only structuredContent widens.
One more limit worth knowing: whether structuredContent itself reaches the
model at all is a host decision, not this server's. Some hosts (OpenAI's
Apps SDK among them) do surface it to the model. A client that opts in on
such a host is choosing to put the full payload into model context.

Errors#

Every failure comes back with isError: true and, with one narrow
exception noted per tool, no structuredContent at all: only content
and the flag. Branch on isError before parsing. Never string-match the
message text.
ClassMessage shapeCause
Reference not available (403/404)That reference is not available on this account. Check it against the value the corresponding list tool returned, and retry. If the reference is correct, this account may not have access to it.The reference does not exist, is malformed, belongs to another corporate, or this account lacks permission for it. All four render identically and carry no status code, deliberately: telling them apart would answer "does this reference exist elsewhere in Sokin". The middle sentence names the tool's own reference arguments and the list tool that produces each — every tool taking a reference declares one (sokin_tool(reference_hint=...)), enforced by test_every_tool_taking_a_reference_names_it. Only list_corporates and list_financial_institutions, which take no reference, show the generic middle sentence.
B2B API error (any other status)API error (400): <message from the API>The upstream Sokin API rejected the call for a stated, non-reference reason. The status and message are passed through as is — except that a message carrying the identity-provider tenant name, an internal principal id or a permission string is replaced with no further detail available.
Unexpected failureSomething went wrong while processing that request. Please try again, and contact support if it keeps happening.Anything unanticipated: a malformed upstream response, a bug. Internal detail is deliberately not leaked.
Schema validationRejection from the MCP layer, before the tool body ever runsA wrongly typed argument, or a value outside a closed Literal enum.
Tool-local validationUsually Error: <specific problem>, but not reliably; see belowPer-tool argument checks: blank references, unsupported pagination parameters, malformed amounts. Listed per tool below.
Most tool-local validation messages start with Error: , but two do not:
visualize returns markdown must not be empty. and
list_financial_institution_branches returns Unknown financial institution id: '999'. Call list_financial_institutions to see valid ids. That
inconsistency is exactly why the rule above is to branch on isError: no
prefix, and no substring, is a dependable signal across every tool.
A message starting with Error: is not on its own proof that isError is
true.
create_payment_instruction and create_fx_payment_instruction can
both return content text that begins with the literal word Error: (an
expired-or-invalid-quote message on the FX-payment side, for instance) while
isError stays false -- these are reported findings from the validation
dry run, not tool failures, and the draft is still real. Branching on the
prefix instead of the flag gets exactly this case backwards.
An upstream response that is structurally unusable is reported as a
synthesized 502 through the same channel rather than rendered as a false
success. validate_payment_instruction does this when a 200 arrives with
no verdict field; the create/submit tools do it when a 202 arrives with no
externalReference.

Pagination#

Cursor-based, never page/offset. A paginated response carries:
{
  "data": [ "..." ],
  "pagination": {
    "nextToken": "eyJrIjoi...",
    "previousToken": null,
    "totalItems": 128,
    "hasMore": true
  }
}
When hasMore is true, pass nextToken back as the next_token argument.
The casing difference is not a typo and the two are not interchangeable:
nextToken is the field the API returns, and next_token is the parameter
this server's tools declare, so the value crosses from one convention to the
other. Every response field on this page is camelCase and every tool parameter
is snake_case, without exception.
No tool here exposes a previous_token parameter, so previousToken is
informational only.
ToolPagination
get_account_ledgerCursor: limit, next_token
list_instructionsCursor: limit, next_token
list_beneficiariesCursor: limit, next_token
list_financial_institution_branchesCursor: limit, next_token
list_accountsEnvelope present but inert: the upstream route hardcodes hasMore: false and accepts no pagination parameter at all, so this always returns everything in one call
list_corporatesNone
list_financial_institutionsNone; the upstream endpoint accepts no parameters at all

Tools#

19 of the 26 tools are read-only (readOnlyHint: true) and move no money.
Seven are writes, and they are not equivalent — only three of the seven move
money:
ToolAnnotationsWhat it does
create_fx_quotereadOnlyHint: false, destructiveHint: false, idempotentHint: falseMints a real, short-lived FX quote. Additive: it only ever creates, never mutates or removes, and it moves no money on its own
settle_fx_quotereadOnlyHint: false, destructiveHint: true, idempotentHint: trueExecutes a real FX conversion between two of the corporate's own currency accounts
submit_payment_instructionreadOnlyHint: false, destructiveHint: true, idempotentHint: trueSubmits a prepared payment to a beneficiary
submit_fx_payment_instructionreadOnlyHint: false, destructiveHint: true, idempotentHint: trueSubmits a prepared FX payment: converts and pays a beneficiary in one motion against a live quote
validate_beneficiaryreadOnlyHint: false, destructiveHint: false, idempotentHint: trueVerifies payee details; its existing-payee mode persists refreshed validation statuses (convergent)
create_beneficiaryreadOnlyHint: false, destructiveHint: false, idempotentHint: falseCreates a payee behind an emailed one-time code; a repeat call creates a second payee (no upstream dedupe)
update_beneficiaryreadOnlyHint: false, destructiveHint: true, idempotentHint: trueOverwrites a payee's destination/identity details behind the same emailed code
Hosts prompt before running any destructive tool. The corresponding
prepare-side tools stay on the read-only default: validate_payment_instruction
is a pure dry run, and both create_payment_instruction and
create_fx_payment_instruction prepare a draft for a human to look at
without ever touching the write endpoint -- though create_fx_payment_instruction's
own read-only badge deserves a closer look than that sentence gives it; see
its own entry below. The beneficiary writes use a different human-in-the-loop
than the payment pair: an emailed one-time code the model cannot forge (see
create_beneficiary).
That split describes today's tool set, not a permanent property of this
server.
The release of any write tool to production is gated behind a
separate go-live decision independent of what UAT exposes. The catalogue
below reflects UAT; re-read the environment note in the Server section above
before depending on a write tool in production.
None of these money-moving flows is a single call. Each is a pair (or, for
FX quoting, a triple) joined by a server-side draft gate, and the shape is
worth understanding before integrating against any of them.

The two payment pairs no longer require an MCP UI host#

Both create_payment_instruction/submit_payment_instruction and
create_fx_payment_instruction/submit_fx_payment_instruction used to
hard-refuse a session that had not positively declared MCP Apps (SEP-1865)
support at initialize time. That guard was removed from all four tools
(Vuk's ARC-455 review, finding #2): the real human control for a payment is
the corporate's own approval configuration in the Sokin B2B portal, not the
review widget. ARC-587 seeds an approval rule for every corporate, closing
the case where a corporate with no approval rules configured would otherwise
have had it approved on creation -- but that rule is deactivatable, and an
unresolved calling channel is not gated (see settle_fx_quote's "what the
gate does not guarantee" section for both edges, which apply identically
here). Refusing a plain-chat client bought no additional safety and only
blocked a legitimate create -> confirm in chat -> submit flow for a host
with no widget surface at all.
Only create_fx_quote and settle_fx_quote keep a hard capability refusal
(see server.py's own UI_ONLY_TOOL_NAMES set and settle_fx_quote's entry
below). It was introduced when the widget's Settle click was the only human
control on an FX settlement. It no longer is: B2B's ARC-587 approval rule now
sends MCP-originated FX settlements to portal approval, and the refusal stays
as defence in depth behind it (see "What the gate does not guarantee" under
settle_fx_quote).
All four payment-pair tools still read the session's declared capability --
just to choose which of two response texts to show, never to refuse:
MCP UI declared: the create_* tool renders its review card and its
text says so ("awaiting the user's confirmation in the review widget").
Only the user's Approve click there invokes the paired submit_* tool.
Not declared, or unknown (the common case for a plain-chat client):
there is no card. The create_* tool's own content text spells the
idempotencyKey out in plain words -- structuredContent is not reliably
surfaced to the calling model, so the key has to travel where the model
can actually see it -- and instructs the calling model to read the draft
and fees back to the user, get their explicit approval in chat, and only
then call the paired submit_* tool itself with that key and
byte-identical arguments.
structuredContent does not change between these two branches: idempotencyKey
is present in both once validation passes. Only the content wording
changes, and both submit_* tools accept a direct call under either
branch -- there is no special "widget-only" enforcement on the submit side,
and there never was one for this pair.
Payments prepare in the conversation and commit from a review card (or, for
a non-UI client, from an explicit chat confirmation):
validate_payment_instruction and create_payment_instruction both persist
nothing upstream and move no money; the first is a dry run, the second
prepares a draft for a human to look at. Only submit_payment_instruction
commits, and it will only ever send a byte-identical copy of the draft that
was prepared, under a key the server minted itself. See its own entry for
what that gate does and does not guarantee.
create_fx_payment_instruction and submit_fx_payment_instruction follow a
similar prepare/review/approve/submit shape, with the same draft-gate
guarantees -- but create_fx_payment_instruction no longer takes a quote_id.
Per Vuk's review of the same ticket (finding #1), it now takes raw pricing
inputs (buy_currency, sell_currency, amount, fixed_side,
corporate_reference) and mints its own quote internally, via the same FX
quote adapter create_fx_quote uses, rather than requiring a quote minted
by a separate earlier call. The minted quote's id then travels inside the
registered draft, which is why submit_fx_payment_instruction still takes
quote_id as a parameter: it has to match that same draft. See both
entries below.
FX quoting runs its whole lifecycle inside one card instead, because a
quote expires too quickly to survive a round trip through a conversation:
Both create_fx_quote and settle_fx_quote exist only to back that card's
own buttons, and both are refused outright for a client that has not
declared MCP UI support. See settle_fx_quote's entry for why, and for what
its gate does and does not guarantee.

list_corporates#

List Corporates · read-only · touches an external system
Lists the corporates the authenticated user can access. This is the entry
point for most sessions: nearly every other tool needs a
corporateReference, and this is where one comes from. Takes no arguments.
Parameters: none.
content, one sentence:
2 corporates: CORP-8842 — Northwind Trading Ltd (direct assignment); CORP-9110 — Northwind Logistics BV (inherited access).
structuredContent, envelope {"data": {"items": [...]}}, each item:
FieldTypeDescription
corporateReferencestringThe reference to pass to every other tool
corporateNamestringDisplay name
assignmentTypestringdirect or inherited
Full response adds: activeFrom (an ISO-8601 timestamp nothing in this
server consumes) and features (an opaque corporate feature-flag object
passed through without being read). It also restores the envelope's
message field, which the compact response strips.

list_accounts#

List Accounts · read-only · touches an external system
Lists all Corporate Currency Accounts (CCAs) for a corporate, with balances.
ParameterTypeRequiredDescription
corporate_referencestringyesThe corporateReference from list_corporates
content, one sentence:
2 accounts for CORP-8842: CCA-1001 (GBP) — balance 48200.00, of which 47150.00 available; CCA-1002 (USD) — balance 12000.00, available balance not computable.
availableBalance is nullable upstream: it is currentBalance minus the
amount reserved by outstanding instructions, and it cannot be computed when
the caller lacks Instruction Read permission. The summary states that in
words rather than rendering an empty value.
structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}}.
The pagination object is always present here but inert: nextToken and
previousToken are always null and hasMore is always false; the upstream
route accepts no pagination parameter at all. Each item:
FieldTypeDescription
corporateReferencestringThe corporate that owns this account
corporateCurrencyAccountReferencestringThe account reference used by other tools
currencyCodestringISO 4217
balancedecimalCurrent balance
availableBalancedecimal, or nullBalance minus outstanding instruction reservations; null when not computable
Full response adds: nothing. Compact is the identity of raw here; every
field is caller-actionable, so nothing is trimmed.

get_account#

Get Account · read-only · touches an external system
Full detail for one Corporate Currency Account, including the pay-in
details someone would use to fund it.
ParameterTypeRequiredDescription
account_referencestringyesThe corporate currency account reference
content, a sentence per funding route:
Account CCA-1001 (GBP): balance 48200.00, of which 47150.00 available. Local funding: active — ready to receive payments — pay into Northwind Trading Ltd, account 12345678, SortCode 04-00-75, at Modulr FS Limited (London branch), GB. International funding: not yet set up.
structuredContent, envelope {"message": "Success.", "data": {...}}:
FieldTypeDescription
externalReferencestringThe account reference
currencystringISO 4217
currentBalancedecimalCurrent balance
availableBalancedecimal, or nullA computed value: currentBalance minus outstanding instruction reservations, null when not computable. The value it is computed from, outstandingInstructionBalance, is never itself serialized on the wire
localAccountStatusstringLocality status, see the enums appendix
internationalAccountStatusstringLocality status, see the enums appendix
localPaymentDetailsobject, or nullPay-in details for the local route
internationalPaymentDetailsarrayPay-in details for international routes; defaults to an empty list, may hold several options
Each payment-details object carries accountName, accountNumber,
accountType, iban, routingCodeType, routingCode, bicCode, email,
corporateReference, bankName, bankCountry, bankAddress,
bankBranch.
Full response adds: version only, an optimistic-concurrency counter
with no user-facing meaning.
Validation errors: account_reference must not be blank. This is
enforced locally rather than left to the upstream API because a blank path
segment collapses the request down to the account collection route; it
would return a list of accounts instead of erroring.

get_account_ledger#

Get Account Ledger · read-only · touches an external system
Transaction history for one account. Cursor-paginated (this is a genuinely
paginated endpoint, unlike list_accounts).
ParameterTypeRequiredDescription
account_referencestringyesThe corporate currency account reference
from_datestringnoFilter from this date (YYYY-MM-DD)
to_datestringnoFilter to this date (YYYY-MM-DD)
limitintegernoItems per page (default 25, max 100)
next_tokenstringnoCursor from the previous page's pagination.nextToken
content, a markdown table, amounts signed for readability:
3 ledger entries for CCA-1001 (amounts shown as +credit/-debit):
DateTypeAmountCounterpartyDescription
2026-08-14deposit+5000.00Contoso LtdInvoice 4471
2026-08-15withdrawal-1250.00Fabrikam GmbHSupplier payment
2026-08-16FX Trade-800.00GBP to EUR conversion
The counterparty column reads sourceReference for credit rows and
beneficiary for debit rows; the two are mutually exclusive upstream,
gated by direction.
structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}},
each entry (8 fields, the complete set on this model, nothing trimmed):
FieldTypeDescription
amountdecimalUnsigned amount. The sign shown in content is derived from direction
directionstringcredit or debit in practice (see the enums appendix); typed as a plain string upstream, not an enforced enum
typestringEntry type. Only FX and forward legs get a friendly label; every other kind appears under its own raw snake_case value. See the enums appendix for the full set
effectiveDatedateDate the entry takes effect
sourceReferencestring, or nullCounterparty on credit entries
beneficiarystring, or nullCounterparty on debit entries
descriptionstring, or nullFree text
transactionReferencestring, or nullLogical reference tying together every leg of one transaction
Full response adds: nothing; this is already the complete raw model.
transactionReference is kept in the compact response deliberately despite
looking like an internal correlation id: it is the reference that makes
reconciliation possible.
Validation errors: account_reference must not be blank.

list_instructions#

List Instructions · read-only · touches an external system
Lists payment instructions for a corporate or for a single account.
Cursor-paginated.
ParameterTypeRequiredDescription
corporate_referencestringone ofThe corporate to list for
cca_referencestringone ofThe account to list for
completedbooleannotrue for completed, false for in progress
limitintegernoMax instructions (default 20, max 100)
next_tokenstringnoCursor from the previous page
Exactly one of corporate_reference or cca_reference is required.
content, a markdown table:
2 instructions for CORP-8842:
ReferenceAccountTypeAmountCurrencyStatusCreated
INS-5512CCA-1001Payment1250.00GBPProcessed2026-08-15T09:12:44Z
INS-5513CCA-1002FXPayment800.00USDPending Settlement2026-08-16T11:03:02Z
structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}},
each item:
FieldTypeDescription
instructionReferencestringThe instruction's external reference
corporateReferencestringOwning corporate
ccaReferencestringSource account. A corporate can hold several accounts in one currency, so currency alone does not identify it
instructionTypestringSee the enums appendix
amountdecimalInstruction amount
currencystringISO 4217
displayStatusstringUser-facing status, see the enums appendix
createdAtdatetimeCreation timestamp
updatedAtdatetimeLast update timestamp
This list summary is a lighter model than get_instruction's full detail
response below; it does not carry fees, beneficiary, or currency-conversion
fields at all.
Full response adds: status, the internal instruction status. The
API's own field description calls it internal, and displayStatus already
carries the user-facing equivalent.
Validation errors:
Error: You must provide either corporate_reference or cca_reference.
Error: Provide only one of corporate_reference or cca_reference, not both.

get_instruction#

Get Instruction · read-only · touches an external system
Full detail for one instruction: status, currencies, fees, beneficiary.
ParameterTypeRequiredDescription
instruction_referencestringyesThe instruction reference to look up
content, a sentence, with optional clauses appearing only when the
underlying field is populated:
Instruction INS-5513 (FXPayment) for corporate CORP-8842, account CCA-1002: 800.00 USD -> 742.10 EUR, status Pending Settlement. Beneficiary: BEN-3301. Sender reference (shown to the recipient): INV-4471. Purpose: SUPPLIER_PAYMENT. Fees: 4.20 FX, 1.50 transaction, 0.00 reseller. Created 2026-08-16T11:03:02Z, updated 2026-08-16T11:04:18Z.
structuredContent, envelope {"message": "Success.", "data": {...}}.
The full upstream model has 25 fields; the compact response drops 3 of them
(version, status, quoteId), leaving 22, plus 2 more synthesized ones
described below (rejectedBy, rejectionComment) present only when a
rejection is detected and the approval lookup succeeds:
FieldTypeDescription
externalReferencestringThe instruction reference
createdAtdatetimeCreation timestamp
updatedAtdatetimeLast update timestamp
failureReasonstring, or nullWhy it failed, when it did
displayStatusstringUser-facing status, see the enums appendix. Can be the synthesized value Rejected, described below -- not a raw backend value
rejectedBystring, or absentPresent only when displayStatus is the synthesized Rejected AND the approval lookup succeeded. The rejecting user's raw id (IdP scheme, tenant and principal id, e.g. waad|Sokin-Platform-UAT|7ca5ded2-...); this server has no name-resolution for it, which is also why it's left out of this tool's content text and only present here
rejectionCommentstring, or absentPresent under the same condition as rejectedBy. The rejecting approver's own comment (mandatory on their end)
instructionTypestringSee the enums appendix
corporateReferencestringOwning corporate
corporateCurrencyAccountstringSource account
amountdecimalSource amount
destinationAmountdecimal, or nullDestination amount; set only when a conversion happens
sourceCurrencystringISO 4217
destinationCurrencystringISO 4217
beneficiaryReferencestring, or nullThe payee
senderReferencestring, or nullText shown to the recipient
paymentPurposestring, or nullPurpose code
paymentReferencestring, or nullPayment reference
sokinFxFeedecimalFX fee
sokinTransactionFeedecimalTransaction fee
resellerTransactionFeedecimalReseller fee
destinationCorporateReferencestring, or nullDestination corporate, when distinct from the source
destinationCorporateCurrencyAccountstring, or nullDestination account, when applicable
instructionRequestReferencestring, or nullThe request this instruction came from. Pass it to get_instruction_request
Full response adds: version (an optimistic-concurrency counter),
status (the internal status; the API's own field description calls it
internal, and even that description is known to be incomplete versus the
real enum, so prefer displayStatus), and quoteId (an internal
correlation id no tool here reads). It is not a strict superset of the
compact response for a rejected instruction
: displayStatus: "Rejected"
and rejectedBy/rejectionComment are this server's own inference, never
present in the backend's own payload, so the full response's
structuredContent shows the raw displayStatus: "Failed" with neither
extra field, even while content (built from the compact view regardless
of which response mode was requested) still says "Rejected". Use the
compact response (the default) when this distinction matters; the full
response trades it away for an exact, unmodified copy of what the backend
actually sent.
A rejection during approval vs. a real failure: the B2B API itself has
no distinct status for an instruction being declined during approval --
both that and a genuine processing failure come back as
displayStatus: "Failed". This server detects a rejection from an exact
"Approval rejected: " prefix on failureReason and relabels
displayStatus to Rejected before returning it -- a value the backend
itself never sends, present only in this tool's response. This is usually
a human declining the instruction in the approval portal, but not always:
a bulk-file import failure or a customer cancellation resolve an
instruction's approval as a rejection the same way, with the identical
prefix and no portal decision involved -- always read failureReason for
the real reason rather than assuming a portal decline. When the rejection
did come from an actual approval decision, this server also calls a
second, independent endpoint (GET /approval-evaluations/by-instruction/ {reference}) to find the rejecting decision and adds
rejectedBy/rejectionComment. That second call can fail on its own
(404 when no approval evaluation exists, a transient error), and finds
nothing for a rejection with no underlying approval decision (the
bulk-file and cancellation cases above); either way, displayStatus is
still Rejected but the two extra fields are simply absent. This
Rejected is unrelated to get_instruction_request's own Rejected (see
its statuses below) -- that one means the request never became an
instruction at all.

get_instruction_request#

Get Instruction Request · read-only · touches an external system
Reads the outcome of a submitted payment. A submitted payment becomes an
instruction request first and is processed asynchronously: acceptance at
submission does not mean the payment was created. This is the way to get
from a submission to the instruction it produced.
ParameterTypeRequiredDescription
instruction_request_referencestringyesThe instruction request reference exactly as the API returned it (currently IR--prefixed, e.g. IR-Payment-..., though treat the prefix as convention, not contract). Not an instruction reference; those belong to get_instruction. If you only have an instruction, its instructionRequestReference field is this value.
Read status carefully: the names run counter to their meaning.
Created is the initial, still-processing state, not a success. Accepted
is terminal success. Rejected is terminal failure. These are the only
three values this field takes.
content, one sentence, branching on status:
Instruction request IR-Payment-1 is still being processed -- no outcome yet. Check again shortly.
Instruction request IR-Payment-1 was accepted: instruction INS-42 was created. Use get_instruction for its full detail.
Instruction request IR-Payment-1 was rejected: Insufficient balance (category: amount). No instruction was created.
createdInstructionReference is nullable on the contract even when the
status is Accepted; in that case the sentence ends "accepted; the created
instruction's reference is not available yet" rather than rendering a
literal None. A status outside the three above is echoed verbatim (... has status <value>.) rather than reinterpreted.
structuredContent, envelope {"message": "Success.", "data": {...}},
5 fields:
FieldTypeDescription
externalReferencestringThe instruction request reference
statusstringCreated, Accepted, or Rejected; see the enums appendix
createdInstructionReferencestring, or nullThe instruction produced on acceptance. Feed it to get_instruction. Nullable even when Accepted
failureReasonstring, or nullWhy the request was rejected
errorCategorystring, or nullThe field or category that caused the rejection. See the enums appendix
Full response adds: version, an optimistic-concurrency counter.
Tool-specific errors: a blank reference is rejected locally before any
call. A response whose payload is missing externalReference or status
is rejected as an unexpected shape rather than reported as a confident
success. A not-found is usually final: the reference is wrong (commonly an
instruction reference passed here by mistake), or the request belongs to a
different login. The one transient case is a request submitted moments ago,
which can briefly read as not-found while still being persisted; retry
once, then stop.

validate_payment_instruction#

Validate Payment Instruction · read-only · touches an external system
Dry-runs a payment: the same checks real creation performs (account,
beneficiary, balance, fee resolution) run synchronously with nothing
persisted and no money moved.
Same currency only. This tool never converts currencies -- the payment
goes out in the source account's own currency. A beneficiary that exists
but has no destination in that currency fails with errorCategory: "beneficiary_currency" and a reason naming the currency, instead of a false
"beneficiary does not exist" (see the enums appendix for why that
distinction needs an MCP-server-side check at all) -- but this is
best-effort, not a guarantee. It depends on the account's currency
resolving, the corporate reference parsing out of the account reference,
and a beneficiary lookup all succeeding; when any of those fails, the
backend's own answer (which does conflate the two cases) passes through
unchanged. A caller should check list_beneficiaries before assuming
"does not exist" means a payee needs creating. For a cross-currency
payment to that same beneficiary, use create_fx_payment_instruction
instead.
It is marked read-only despite issuing a POST upstream, and that is not a
convenience label. The validate handler is wired with no repositories at
all (unlike the create handler beside it), and the engine it runs performs
no writes. The path holds nothing capable of persisting; the wire verb is
an artifact of sending a request body.
ParameterTypeRequiredDescription
corporate_currency_accountstringyesSource account reference from list_accounts. Currency and corporate are derived from it
beneficiary_referencestringyesThe payee, from list_beneficiaries. Must already exist
amountstringyesPlain decimal, max 2 places, greater than zero (e.g. "125.50")
reseller_feestringnoSame format as amount, zero or greater
sender_referencestringnoShown to the recipient, max 18 characters
payment_purposeenumnoOne of 22 declared purpose codes (e.g. ACCOUNTS_PAYABLE); see the enums appendix. An unrecognized value is rejected by schema validation, not sent
amount_is_fees_inclusivebooleannotrue takes fees out of amount; default false adds them on top
content, a verdict sentence:
Validation passed: this payment would clear the creation checks. Total debit 1256.70 (5.20 Sokin fee, 1.50 reseller fee); beneficiary would receive 1250.00. Nothing was created and no money moved.
Validation failed: insufficient available balance on the source account (category: amount). The engine still computed amounts: total debit 1256.70 (5.20 Sokin fee, 1.50 reseller fee). Nothing was created and no money moved.
structuredContent, envelope {"data": {...}}:
FieldTypeDescription
successbooleanWhether the dry run passed every check
failureReasonstring, or nullWhy it failed
errorCategorystring, or nullMachine-readable failure category. Not a closed set on the wire; see the enums appendix
feeBreakdownobject, or nullAmounts, when the engine got far enough to compute them
feeBreakdown carries amount (total debit including fees, required),
destinationAmount (what the beneficiary receives, nullable), sokinFee
(required), resellerFee (required), and feeRateCardReference (nullable).
It is populated on failures too; an insufficient-balance rejection still
shows how short the account is.
Full response adds: the envelope's message field, the literal string
"Success." on every call regardless of the verdict. It describes the HTTP
exchange, not the validation, and reading it as a result would be a bug.
Validation errors:
Error: corporate_currency_account must not be empty.
Error: beneficiary_reference must not be empty.
Error: amount must be a plain decimal number with at most 2 decimal places (e.g. '125.50'), got '1e3'.
Error: amount must be greater than zero.
Error: reseller_fee must be ... (same format rule, but zero is allowed)
Error: sender_reference must be at most 18 characters, got 24.
The amount rule is deliberately stricter than the upstream API's. Pydantic
would parse "1e3" as 1000 and accept signed forms; for anything
payment-shaped, silent reinterpretation is the worst available failure
mode, so only a plain unsigned decimal passes here.
A malformed upstream response is a hard error. A 200 whose body lacks
the success verdict is rejected as a synthesized 502 with the message
"API returned an unexpected response shape for the validation, the result
is unknown; do not report the payment as passed or failed." A missing
verdict must never render as a failed validation.

create_payment_instruction#

Create Payment Instruction · read-only · touches an external system
Prepares a payment for a human to review. It submits nothing. Despite
the name, no payment exists after this call and no money has moved.
Same currency only, same reason and same errorCategory: "beneficiary_currency" as validate_payment_instruction above -- this
tool runs the same check before ever calling the backend, so a
cross-currency beneficiary produces the same distinct reason here instead
of an approvable-looking draft.
What it actually does: resolves the source account's currency, its
visible identifier (the IBAN or account number shown in the Sokin
portal), and the beneficiary's display name and country with two
read-only lookups (account and beneficiary), runs the currency-mismatch
check against that same currency, re-runs the validate dry run so the
review can show server-computed fees rather than model-relayed figures,
mints an idempotency key server-side, and registers the normalized draft
against that key. When the calling session has declared MCP UI support it
also renders its own MCP UI embed (ui://sokin-mcp/payment-review), a
review card with Approve and Cancel; only the user's Approve click there
submits, by invoking submit_payment_instruction from inside the widget.
Call validate_payment_instruction first. This tool's own description
instructs the calling model to validate and iterate with the user (fixing
inputs, asking for missing data) until validation passes, and only then
prepare. The dry run this tool re-runs internally is a guard, not the
pre-flight: a draft that fails it produces no approvable draft and no
registered key, costing the user a round trip.
MCP UI support changes only which text this tool shows you, not whether
it will run.
Earlier revisions of this pair hard-refused a session that
had not positively declared MCP Apps support -- that guard came off both
create_payment_instruction and submit_payment_instruction per Vuk's
review of ARC-455 (finding #2; see server.py's own comment on
UI_ONLY_TOOL_NAMES). The real human control for a payment is the
corporate's own approval configuration in the Sokin B2B portal, not this
tool's widget -- ARC-587's rule and its edges are covered above, under "The
two payment pairs no longer require an MCP UI host." Refusing a plain-chat
client bought no additional safety and just blocked a legitimate create -> confirm in chat -> submit flow.
The session's declared capability is still read, but only to choose the
wording:
MCP UI declared: the card renders, and the text says "awaiting the
user's confirmation in the review widget." Only the widget's Approve
click should invoke submit_payment_instruction.
Not declared, or unknown: there is no card. The text spells out the
idempotencyKey in plain words -- structuredContent is not reliably
surfaced to the calling model, so the key has to travel where the model
can read it -- and instructs the calling model to read the draft and fees
back to the user, get their explicit approval in chat, and only then call
submit_payment_instruction itself with that key and byte-identical
arguments.
structuredContent does not change between the two branches: idempotencyKey
is present in both once validation passes. Only content changes.
Earlier revisions of this page also said this pair "fails safe" on a
non-UI host because no card would appear, so nothing could be approved.
That was never accurate -- the prepare half always returned the idempotency
key in its structuredContent, which was by itself enough for a caller to
invoke the submit half directly and skip human confirmation. Nothing in
today's design closes that by refusal; what actually stands between a model
seeing the key and a real submission is the explicit-approval instruction in
the non-UI text above, plus the host's own confirmation prompt on
submit_payment_instruction's destructive annotation. Treat that prompt as
load-bearing, not decorative, if you are embedding this server in your own
agent -- see submit_payment_instruction's own entry for the full
reasoning.
It is marked read-only for the same reason validate_payment_instruction
is: its only upstream call is the side-effect-free dry run. It does write
one piece of server-side state (the draft record the paired submit tool
checks against), which is gate bookkeeping, not a business write.
ParameterTypeRequiredDescription
corporate_currency_accountstringyesSource account reference from list_accounts. Currency and corporate are derived from it
beneficiary_referencestringyesThe payee, from list_beneficiaries. Must already exist — if it doesn't, create it first with create_beneficiary (needs a one-time emailed code)
amountstringyesPlain decimal, max 2 places, greater than zero (e.g. "125.50")
reseller_feestringnoSame format as amount, zero or greater
sender_referencestringnoShown to the recipient, max 18 characters
payment_purposeenumnoOne of 22 declared purpose codes (e.g. ACCOUNTS_PAYABLE); see the enums appendix. An unrecognized value is rejected by schema validation, not sent
amount_is_fees_inclusivebooleannotrue takes fees out of amount; default false adds them on top
status_check_keystringnoLeave unset. Only the review widget passes it, with the idempotency key it was given, to ask whether that draft was already submitted. With it the tool prepares nothing and makes one read-only call, for the account; see "Status check" below
content, when MCP UI is declared -- a not-submitted notice worded so a
model relaying it cannot truthfully claim the payment was made:
Payment prepared and validated -- awaiting the user's confirmation in the review widget. Nothing has been submitted; do not report this payment as made, and do not call submit_payment_instruction yourself.
content, when MCP UI is not declared (or unknown) -- the idempotency
key spelled out, and explicit chat-confirmation instructions:
Payment prepared and validated -- nothing has been submitted yet. idempotency key: 3fae1c9e-... . Read the draft and fees back to the user and get their explicit approval; only after they approve, call submit_payment_instruction yourself with this idempotency_key and the exact same arguments. Never submit without that approval, and never tell the user the payment was made until submit_payment_instruction succeeds -- it will still need approval in the Sokin portal afterward.
When the dry run fails, there is no approvable draft and no registered key
(this text is the same in both branches), and the text says what to do
instead:
The payment could not be validated: insufficient available balance on the source account. Fix the inputs (validate_payment_instruction is the pre-flight) and prepare again. Nothing has been submitted.
structuredContent: this tool builds its own payload, so there is no
{"data": ...} envelope:
FieldTypeDescription
draftobjectThe normalized payment exactly as it would be submitted
currencystring, or nullThe source account's currency, for display. Null if the lookup failed (display sugar that never blocks a valid draft). Absent entirely on the validation-failure branch
accountIdentifierstring, or nullThe account's visible identifier ("IBAN ..." or "account ..."), the same one shown in the Sokin portal. Null if the lookup failed
beneficiaryDisplayNamestring, or nullThe payee's human-readable name, for display instead of the raw reference. Null if the lookup failed
beneficiaryCountryCodestring, or nullThe payee's country, alongside the display name. Null if the lookup failed
validationobjectThe dry-run verdict, same shape as validate_payment_instruction's data, including feeBreakdown
idempotencyKeystring, or nullServer-minted, registered to this exact draft. Null when validation failed, because no approvable draft exists
duplicateWarningobjectPresent only when an identical draft was already submitted: instructionRequestReference and submittedAt of that earlier payment
All four display fields (currency, accountIdentifier,
beneficiaryDisplayName, beneficiaryCountryCode) come from two
read-only lookups this tool makes itself, synchronously, before the card
ever renders -- never from the widget making its own round trip after the
fact. Each fails soft to null independently; a lookup failure never
blocks an otherwise-valid draft, and the caller falls back to the raw
reference rather than a bare "unavailable" message.
draft uses the tool's own snake_case parameter names, not the API's
camelCase: corporate_currency_account, beneficiary_reference, amount,
reseller_fee, sender_reference, payment_purpose,
amount_is_fees_inclusive. It sits next to camelCase siblings like
idempotencyKey in the same object because it is the tool's argument set,
not an upstream payload. Normalization strips every string and uppercases
payment_purpose; a whitespace-only optional becomes null.
duplicateWarning is not an error. It means this exact payment has
already been submitted. When a widget is present it renders an
already-submitted card with no Approve button, then follows that payment's
current status from its instructionRequestReference (waiting for
approval, completed, rejected or failed), since submitted is not the same as
paid; either way submit_payment_instruction
refuses the draft unconditionally, and the block does not expire. A
deliberate second identical payment is prepared with a distinguishing
sender_reference, which makes it a different draft. There is no
acknowledgement flag to override this by design: any flag a caller could
set would be one a model could set too.
Full response adds: nothing. This tool assembles its own payload, so
the compact and full responses are the same object.
Validation errors: the same set validate_payment_instruction lists,
over the same normalized draft. A draft that renders as reviewable is one
submit would also accept.
Draft lifetime. The key and the reviewed draft live in the server's
session store. Pending drafts expire after 24 hours; an expired key is
refused and the payment is simply prepared again. Submitted records are
kept with no expiry, mirroring the backend's permanent dedupe. Hosts may
re-execute a read-only tool freely (on session restore, for instance); each
execution mints a fresh key and registers a fresh draft, which is why
unapproved drafts expire rather than accumulate.
Status check (status_check_key). Hosts replay this tool's stored
result to the review card whenever it remounts, for instance when a
conversation is reopened, and do not call the tool again. That replayed
result predates any approval, so on every mount the card asks this tool
again, passing its own draft and its own idempotency key as
status_check_key. With it, the tool answers from the server's session
store and returns before anything else: no dry run, no beneficiary lookup
and no new key. It makes exactly one call to the Sokin API: it reads
corporate_currency_account with the caller's own token, and answers only
if that succeeds. The one exception is an empty account, which answers
expired with no call at all, since no prepared draft can have one. The session store is shared by every user, so without
that read anyone signed in could ask about another corporate's payment. A
refused read comes back as the usual error, identical whether or not the
payment exists, and the card shows it as "couldn't check". The card keeps
its original key, so an Approve after a reload is a replay of that key
rather than a new submission.
FieldTypeDescription
idempotencyKeystringThe key that was asked about, echoed back
submissionStatestringsubmitted, in_flight (an identical submission is running now, or its outcome is unknown after a 5xx or timeout, so the payment may already exist; nothing is approvable), pending (not submitted, still approvable under this key) or expired (the pending draft expired, or the key was not registered for these arguments, and no submission is on record; the text says so rather than claiming nothing went out, since a lost record would look the same)
duplicateWarningobjectPresent only when submitted: instructionRequestReference, submittedAt (null if the server did not record the time) and submittedUnderThisKey (true when this key's own draft was submitted, false when an identical draft was submitted under another key). Submitted means Sokin accepted the request, not that the payment completed; the card follows the rest from instructionRequestReference
For a pending draft the content reads:
Status check only -- nothing was prepared and nothing was submitted by this call. This payment draft has not been submitted; it is still awaiting the user's decision in the review widget. status_check_key is for the review widget only; omit it to prepare a payment.
A reply without submissionState did not come from a status check: a
server version that predates this parameter ignores it and prepares a fresh
draft. The card treats that as "couldn't check" and never offers Approve on
the key it returns. A store failure is an error result, never a pending
answer, and so is a draft record in a state the server does not recognise. The
card also refuses a submitted answer that carries no
instructionRequestReference, showing "couldn't check" instead. If no
reply arrives within 15 seconds (a host that never answers a widget's
tools/call), the card shows "couldn't check" too; a reply that arrives
later still renders.
While it waits, the card already shows the payment's details from the
replayed result, since none of them depend on the answer. Only what it
offers waits: the header reads "Checking status", a spinner takes the place
of Approve and Cancel, the FX card starts no quote countdown, and nothing is
sent to the model. "Couldn't check", in flight and expired keep the details
on screen and put their message where the buttons would be.

submit_payment_instruction#

Submit Payment Instruction · destructive, idempotent · touches an
external system
The payment commit. This is the one tool on this server that moves real
money. This call itself cannot be cancelled once submitted, but it does not
complete a payment on its own: the corporate's own approval rules in the
Sokin portal are the real control -- ARC-587's rule and its edges are
covered under "The two payment pairs no longer require an MCP UI host"
above.
When the calling session declared MCP UI support, this tool is invoked by
the payment review widget after the user clicks Approve, and is not meant
to be called directly in that case. When it did not, create_payment_instruction's
own text hands back the idempotency key and instructs the calling model to
get the user's explicit approval in chat first -- calling this tool
directly at that point is the intended path, not a bypass; there is no
separate capability check here that would refuse it. Either way it accepts
only an idempotency key that create_payment_instruction minted, and only
with arguments that match the reviewed draft exactly. It declares
readOnlyHint: false, destructiveHint: true and idempotentHint: true,
and carries no MCP UI embed of its own: when a widget invoked it, its
result renders in that same widget.
ParameterTypeRequiredDescription
idempotency_keystringyesThe key create_payment_instruction minted for this exact draft, from its idempotencyKey field
corporate_currency_accountstringyesMust match the reviewed draft
beneficiary_referencestringyesMust match the reviewed draft
amountstringyesMust match the reviewed draft
reseller_feestringnoMust match the reviewed draft
sender_referencestringnoMust match the reviewed draft
payment_purposeenumnoMust match the reviewed draft
amount_is_fees_inclusivebooleannoMust match the reviewed draft
content, a 202 receipt. Acceptance is not completion:
Payment submitted: instruction request IR-Payment-77 for 125.50 from CCA-1 to BEN-1. This is an instruction-request reference, not a completed payment -- it now needs human approval in the Sokin portal before it is processed. Check the outcome via get_instruction_request.
structuredContent, envelope {"data": {...}} with a single field on a
fresh submission:
FieldTypeDescription
externalReferencestringThe instruction request's reference. Pass it to get_instruction_request for the outcome
That is the whole upstream create response model. Everything about the
payment's actual business outcome is asynchronous, so nothing else is
available yet.
A malformed upstream response is a hard error. A 202 whose body lacks
externalReference is rejected as a synthesized 502 reading "API
returned an unexpected response shape for the payment submission, whether
it was accepted is unknown; check get_instruction_request before
retrying." After a POST that may have landed, "unknown" is the only honest
verdict, and it is never rendered as a failure.
Full response adds: the envelope's message field on a fresh
submission, which the compact response strips. On the replay branch below,
message is already passed through in compact mode.
What the gate refuses, and when. Every refusal below happens before the
server acquires an access token, so a refusal never depends on
authentication being healthy, and an auth outage cannot mask one behind a
generic client error. None of these reach the B2B API, and none create a
payment:
SituationResult
Key unknown, fabricated, or expiredError: unknown idempotency key -- payments are prepared via create_payment_instruction, which mints the key and renders the review widget. If a prepared draft was lost, prepare it again.
Arguments differ from the reviewed draftError: these arguments do not match the draft the user reviewed under this idempotency key. Only the exact reviewed draft can be submitted
Key already used, with different valuesError: this idempotency key was already used to submit a different draft (IR-...). Nothing was submitted for these arguments. Answering with the original's state would read as "your new values were handled"
Identical draft already submitted, under any keyError: an identical payment was already submitted as IR-... at ... -- this submission was refused, no new payment was created. Permanent, not windowed
Identical draft being submitted right nowError: an identical payment is being submitted right now ... Wait a moment, then check the payment's state with get_instruction_request. Concurrent approvals are resolved atomically, so exactly one can win
The duplicate block is checked at submit time, not only at prepare time, so
it also catches two identical drafts that were both prepared before either
was approved. It is content-addressed over the normalized draft, so it
survives a fresh key and a days-old restored session.
A replay is answered, not re-executed. Re-submitting the same draft
under the same key (a double click, a widget remount, a model retry, or a
non-UI client calling this a second time) sends nothing upstream. Instead
the tool reads the original payment's live state and reports it, which a
blind retry against the backend's own dedupe could not do:
This draft was already submitted -- no new payment was created. Instruction request IR-Payment-77 was accepted: instruction INS-9 was created. Use get_instruction for its full detail.
On that branch structuredContent is get_instruction_request's shape
(externalReference, status, createdInstructionReference,
failureReason, errorCategory), not the one-field submission shape. Code
that parses this tool's result must tolerate both. If the original's state
cannot be read, the tool says so rather than guessing.
What the gate does not guarantee. It constrains what can be submitted:
only the prepared draft, only once, only under a key the server minted. It
does not, and within MCP cannot, prove who approved it. There is no
widget-only channel, so idempotencyKey necessarily reaches the model in
create_payment_instruction's structuredContent regardless of whether a
widget rendered. The human step rests on either the review card plus the
host's confirmation prompt, or -- for a non-UI client -- the explicit
in-chat approval create_payment_instruction's own text asks for, plus that
same host confirmation prompt on this tool's destructive annotation either
way. If you are embedding this server in your own agent, treat that prompt
as load-bearing, not optional, in both cases.
The backend independently dedupes forever on (idempotencyKey, type, account), so duplicate money on one key is impossible even if the
server's own draft registry were lost entirely. The gate documented here is
an availability and integrity layer on top of that; the backend remains
the correctness layer.
Outcome tracking. When a review widget is present, it polls
get_instruction_request and then get_instruction, rendering the
payment's journey as a timeline and distinguishing created-and-processing,
created-but-awaiting-your-team's-approval (corporate approval rules), and
rejected or failed. "Needs approval" shows as soon as get_instruction
first reports Approval Initiated, and it is not final: portal approval is
human-paced, so the card keeps polling until the payment is Processed or
Failed. On supporting hosts it also pushes a self-contained summary into
the model's context via ui/update-model-context (MCP SEP-1865): once when
the payment first needs approval, and at each terminal state. That way the
assistant knows where the payment stands without re-querying. Hosts without support, and non-UI clients
entirely, get the same information from get_instruction_request directly.

get_fx_quote#

Get FX Quote · read-only · touches an external system
Requests an FX quote for a buy/sell currency pair. Quoting only: this tool
never books a trade and never moves money itself. What it returns depends
on whether your client declared MCP UI support at initialize time. This
is the only tool on this server whose response shape (not merely its text)
branches on that declaration.
This tool ships its own MCP UI embed (ui://sokin-mcp/fx-quote), an
interactive card covering the whole request, quote, and settle flow, with a
live countdown to expiry. Hosts that declared MCP UI support render it in
place of plain text.
Why this branches: a real FX quote is short-lived, and how short is set
by the provider that priced it rather than by anything the caller asks for
(see create_fx_quote). Minting one before a human ever sees a review
card — the straightforward approach — burns part of that window before
anyone can act on it, and on the shortest-lived pairs most of it. So this
tool never mints a quote itself; it only renders the interactive card, and
the card's own buttons call the two tools below at the moment a person
actually clicks:
Client declared MCP UI support: this call mints no quote and never
touches the rate endpoint; when corporate_currency_account is passed it
makes one read of that settlement account, to label the card. It renders
an empty, unpriced card echoing the requested pair and amount. The
card's "Get Quote" button calls create_fx_quote; its "Settle" button
calls settle_fx_quote. Do not tell the user a rate exists from this
call alone.
Client did not declare MCP UI support, or the declaration is unknown:
makes no upstream call at all, creates no quote and shows no rate: every
B2B rate endpoint persists a real quote, and there is no non-persisting
one. It returns the validated request plus a link to the Sokin portal,
where the user sees the live rate and executes the trade. That link is deployment-configured and
differs per environment; where it is not configured the sentence still
tells the user to execute the trade in the Sokin portal, just with no
URL. A session in this state can never reach create_fx_quote or
settle_fx_quote; see settle_fx_quote's hard refusal below. (This is
the one pair on this server that still hard-refuses a non-UI session --
unlike the payment pairs above, see "The two payment pairs no longer
require an MCP UI host.")
ParameterTypeRequiredDescription
buy_currencystringyesISO 4217 code being bought (e.g. "USD")
sell_currencystringyesISO 4217 code being sold (e.g. "GBP")
amountstringyesThe amount of whichever side fixed_side names
fixed_side"BUY" or "SELL"noWhich side amount refers to. Default "SELL"
corporate_referencestringyesThe corporate to quote for. Always pass it: the upstream API only fills it in when the token itself names one corporate, and then it ignores what you pass, so passing it is never wrong. Call list_corporates first if the user hasn't named one
corporate_currency_accountstringnoThe account to eventually settle from. Used by the card's later Settle step; when passed, the card-rendering branch also reads this account once to label it (the raw reference is never sent anywhere else by this call)
card_idstringnoLeave unset. Only the FX quote card passes it, with the cardId from this tool's own earlier result, to ask what already happened on that card. With it the call is a status check, not a card; see "Card status check" below
When MCP UI is supported:
content is a one-sentence acknowledgement, not a quote:
Ready to quote GBP -> USD, 1000.00 on the sell side. No quote has been created yet -- the card's Get Quote button mints one.
structuredContent is a flat echo of the request, not upstream data:
FieldTypeDescription
buyCurrencystringEchoed request
sellCurrencystringEchoed request
amountstringEchoed request
fixedSidestringEchoed request
corporateReferencestring, or nullEchoed request
corporateCurrencyAccountstring, or nullEchoed request
cardIdstringA fresh id for this card (a uuid4). Minting it writes nothing. It is the card's only handle for asking, after a reopen, what already happened on it
corporateCurrencyAccountDisplaystringThe resolved label, e.g. GBP account 12345678 or GBP IBAN GB33..., from a read of the settlement account. When that lookup fails or yields no identifier it is <sellCurrency> account (number unavailable) and the card data is still returned, never an error. Present only when corporate_currency_account was passed (non-blank); omitted otherwise, and then no upstream call is made at all. The card's requested state shows this label, never the internal reference
No full-response delta in this branch: the only upstream call is the
account read for the label, so there is nothing to widen.
Card status check (card_id). Hosts replay this tool's stored result
to the card whenever it remounts, for instance when a conversation is
reopened, and do not call the tool again. The card's own buttons call
create_fx_quote and settle_fx_quote, and those replies are not stored
in the conversation's transcript, so a reopen has nothing of them to replay.
(This is about replay, not secrecy: the server still assumes the model may
see them; see "What the gate does not guarantee" under settle_fx_quote.)
So the replayed result alone
cannot say whether the card was quoted or settled, and before this check a
reopened card came back at step one with a live Get Quote button, even
after a settled conversion. On every mount the card now calls this tool
again with the same arguments plus card_id. create_fx_quote(card_id=)
records the card's latest quote, and settle_fx_quote records its
settlement (found from the key: the settle tool takes no card id).
With card_id, the tool answers from the server's session store only. It
runs ahead of the capability check and the rate call, makes no call to the
Sokin API, acquires no client and mints nothing. The card's records are
keyed by the caller's user and the card id together, so another user's card
id gets the same answer as an unknown one. That is also why no account read
is needed here, unlike the payment status checks. A card_id the server
could not have minted (not a uuid) is an error.
Each record also carries the request the card was made for: the currencies,
amount, fixed side, corporate and the account as the card passed it. A
record made for a different request than the status check's arguments is
an error, never an answer: it means a quote was made under this card's id
for something else (a model passing another card's id to
create_fx_quote), and neither unquoted nor that quote's own state is
safe to show on this card. The card shows it as "couldn't check", and its
own Get Quote is refused too, so a model that quotes under another card's id
can leave that card unusable (for 24 hours, or for good if it settles). That
is the fail-closed trade: the other card is never shown a quote it did not
make. The user asks for a new card with get_fx_quote.
In the quoted state the reply carries every argument settle_fx_quote
needs, key included, to any caller of this tool, not only the card. The card
needs the original key to offer Settle on a reopen, and the server cannot
tell the card from the model, so this retires the old invariant that the key
never entered the transcript. That is accepted because B2B's ARC-587
approval rule now holds an MCP-originated FX settlement for portal approval;
see "What the gate does not guarantee" under settle_fx_quote for that
defence and its edges. settle_fx_quote also still refuses a session
without MCP UI support.
FieldTypeDescription
cardIdstringThe card that was asked about, echoed back
cardStatestringunquoted (no quote from this card is on record: never quoted, its record expired, or not this user's card), quoted (the latest quote has no settlement on record and is inside its expiresAt; the card shows it live with Settle), expired (the latest quote has no settlement on record and is past its expiresAt; the card offers Get Quote again), settling (a settlement of it is running, or was attempted and its outcome never became known, so the conversion may exist; nothing is offered) or settled (a settlement from this card was submitted to Sokin as an instruction request; that is where the conversion starts, not proof it completed: the card resumes tracking it, through any wait for approval in the Sokin dashboard, until it is processed or fails)
quoteobjectPresent for every state but unquoted: the quote as create_fx_quote returned it. It carries idempotencyKey only when quoted, the one state that offers Settle, and it is null when settled was recorded without the card's quote
instructionRequestReferencestringPresent only when settled: the IR- reference to follow with get_instruction_request
Precedence is settled > settling > quoted/expired > unquoted. A reopened
settled card offers neither Get Quote nor Settle. A second view of the same
card that was mounted earlier still shows Get Quote, so create_fx_quote
itself allows a new quote only on a card with no quote on record or whose
last quote expired with no settlement on record (see its card_id below). A press anywhere
else is refused, and that view re-checks the card and shows what is really
there. Only two presses on an unquoted card at the same moment can still
race. A card record with no settlement expires after 24 hours, the same as
the settlement draft; a card that ever had a settlement attempted keeps a
record of the attempt with no expiry (see below). A settled one is kept with no expiry. For a settled card
the content reads:
Status check only -- nothing was quoted or settled by this call. A conversion from this card was already settled as instruction request IR-FX-6b2f90. Do not quote or settle it again; check get_instruction_request for its outcome. card_id is for the FX quote card only; omit it to show a card.
An outcome nobody learned stays settling for good. A settlement that
ends with a 5xx, a timeout, or a request cut off mid-POST (a cancelled task,
a process killed) may have gone through. settle_fx_quote keeps its claim
for 10 minutes, so a retry is possible after that (and safe: B2B looks an
instruction request up by idempotency key, type and account, and returns
the original instead of creating a second). But the claim alone was not
enough: a quote can live longer than it, since its window is provider-set
and uncapped, and the card then read as quoted with a live Settle, and
once the quote expired, as expired with Get Quote, a new key that the
dedupe cannot catch.
So before the request goes out, settle_fx_quote records the attempt on
the card, with no expiry, and removes it only when the request is
definitely rejected (a 4xx). An attempt whose outcome never became known
keeps the card reading settling however long ago it was, even after the
card's 24-hour quote record has expired, and it also lets the status check
find a settlement through the submitted record after that. A same-key
retry that turns out to have settled then reads settled. To start
another conversion, call get_fx_quote for a new card.
quoted and expired mean "no settlement on record", not "nothing was
settled", and expired never says nothing was settled: a store that lost a
write looks the same. Its text points at the account's recent instruction
requests.
A reply without cardState did not come from a status check: a server
version that predates this parameter ignores it and renders a plain card.
The card treats that as "couldn't check", and never as unquoted. A store
failure is an error result, which the card shows as "couldn't check" with
Try again. Cards rendered before this shipped carry no cardId and render
exactly as before.
When MCP UI is not supported (or unknown):
No upstream call is made and no quote is created. content tells the model
so, names the trade, and points at the portal:
This client cannot show the FX quote card, so no quote was created and no rate is shown. Open https://portal.uat.sokin.com/v2/transfer/currency-exchange to see the live rate and execute GBP -> USD for 1000.00 (SELL side) for corporate CORP-8842. Do not try to quote or settle this through the assistant.
Where the portal link is not configured, the sentence reads "The Sokin
dashboard link is not configured for this deployment; direct the user to
the Sokin dashboard" in place of Open <url>.
structuredContent, flat with no data envelope, unlike every other tool
here:
FieldTypeDescription
buyCurrencystringEchoed request, uppercased
sellCurrencystringEchoed request, uppercased
amountstringEchoed request
fixedSidestringEchoed request
corporateReferencestringEchoed request
indicativebooleanAlways true
quoteCreatedbooleanAlways false
dashboardUrlstring, or nullThe configured portal link, null when not configured
No quoteId, rate, fee or expiry appears anywhere in this response, and
there is no full-response delta: nothing upstream was called.
Validation errors (all branches): checked in this order, before any
upstream call or capability lookup, returning the first failure as an
isError result. create_fx_quote and create_fx_payment_instruction
run the same checks.
FailureMessage
buy_currency / sell_currency is not 3-4 uppercase letters (checked first, buy then sell)Error: buy_currency must be a currency code such as 'USD', got 'usd1'.
buy_currency / sell_currency is well-formed but not a supported currencyError: buy_currency 'ZZZ' is not a currency Sokin supports (e.g. 'USD', 'EUR', 'GBP').
Same currency on both sidesError: buy_currency and sell_currency must differ (both are 'USD').
amount is not a plain decimal (negative, 1e12, three decimals, non-ASCII digits)Error: amount must be a plain decimal number with at most 2 decimal places (e.g. '125.50'), got '-5'.
amount is zeroError: amount must be greater than zero.
amount is above the quote limitError: amount must not exceed 9999999999 (the FX quote limit), got '10000000000'.
The supported set is Sokin's own list of 62 currency codes (including the
four-letter stablecoins USDT and USDC), not all of ISO 4217. It is a copy
of the B2B API's CurrencyCode enum, so a currency upstream adds is refused
here until the copy is updated; upstream remains the final authority.

create_fx_quote#

Create FX Quote · not read-only, not destructive · touches an external
system
Mints a real, short-lived FX quote and reserves a settlement draft for it.
Called by the FX quote card's own "Get Quote" button after get_fx_quote
has rendered the empty card; do not call this directly, call get_fx_quote
first.
Unlike get_fx_quote's display step, this is a genuine write: every call
persists a real quote and calls a live external rate provider.
How long a quote lasts is decided by the provider that priced it, and
it can vary by roughly a factor of five between currency pairs. The window
this server requests (MIN_2, the longest option the API's own
FXValidityPeriod enum offers) is only a fallback that applies when a
provider declares no expiry of its own; a provider that returns its own
valid_until overrides it in either direction. In practice the two live
providers sit at opposite ends: one pins its quotes to exactly the
requested MIN_2 window, the other returns its own valid_until
(observed around ten minutes). Which provider prices a given pair is
resolved upstream and is not exposed in the response.
So the quote's own expiresAt is the only figure to trust, never the
requested window, and never a hardcoded assumption about how long a quote
lasts. Never call this speculatively or more than once for the same
request.
How the quote prices.
The FX fee (fxFees) is already inside sellAmount: the provider is
quoted on the amount net of the fee, so it is not charged on top of the
sell amount and not deducted again from the buy amount.
fxFees is denominated in the sell currency, whichever side is fixed.
fxRate is the all-in customer rate, buyAmount / sellAmount by
construction. Sell amount x fxRate approximates buyAmount with the
fee absorbed, but the rate is truncated to 6 decimals and that error
grows with the amount, so never recompute buyAmount from the rate.
buyAmount is the quote's locked buy leg and is the amount that settles.
The total debited is not stated by the quote: the settlement instruction
may carry separate Sokin or reseller fees on top.
The card shows the fee as
FX <fee> <sell currency>, included in the <sell amount> <sell currency> you sell,
in a Fees row that sits outside the live
quote panel so a reopened settled or settling card shows it too, and the text
result carries the same statement when fxFees is present and positive. The other shapes fxFees
can take are stated too, never silently dropped:
a zero fee: the card shows "no FX fee" and the text says
No FX fee applies; <buy amount> <buy currency> is the amount that settles.;
no fee at all (an empty fxFees): the card shows "none" and the text
carries no fee sentence;
a fee that is present but unreadable (not a finite, non-negative number):
the card shows the raw value with "(could not be read; check it before you
settle)", its "You get" hint says "The FX fee could not be read; check the
Fees line before you settle.", and the text says "The FX fee could not be
read from the quote; check the fee shown on the card before settling."
without claiming what settles.
Annotated readOnlyHint: false, destructiveHint: false, idempotentHint: false: a genuine third state, distinct from a typical write. Additive (it
only ever creates a new quote and draft pair, never mutates or removes
anything) but not idempotent (each call mints a fresh quote and a fresh
idempotency key, even for identical arguments).
Hard-refused for any session that has not declared MCP UI support --
there is no card for such a session to show a quote in, so minting one
would be a real cost (a live provider call, a persisted record) with no
legitimate use:
Error: this session has not declared MCP UI support, so creating a real FX quote is refused here -- there is no card to display it in. Direct the user to the Sokin dashboard to get a live rate and execute a trade instead.
ParameterTypeRequiredDescription
buy_currencystringyesISO 4217 code being bought
sell_currencystringyesISO 4217 code being sold
amountstringyesThe amount of whichever side fixed_side names
fixed_side"BUY" or "SELL"noDefault "SELL"
corporate_referencestringyesSame resolution rule as get_fx_quote, mandatory for the same reason
corporate_currency_accountstringnoThe account settlement would debit. Never sent to the quote-creation endpoint itself, only carried into the reserved draft for the later Settle call. When omitted, the corporate's own account in the sell currency is resolved automatically if there is exactly one match; with zero or several candidates, no account is reserved and the card renders with no Settle option
reseller_feestringnoCarried into the reserved draft only, never sent to the quote-creation endpoint
beneficiary_external_referencestringnoOnly needed if quote creation itself asks for one; some rate providers require it even though this quote never pays a beneficiary
card_idstringnoLeave unset. Only the FX quote card passes it, so that a reopened card can still show this quote and whether it was settled (see get_fx_quote's card status check). With it, the card is looked up first, from the store alone, and only a card with no quote on record or whose last quote expired with no settlement on record gets a new one. A card holding a live quote, already settled, possibly settling, or whose record was made for a different request gets no new quote and nothing is sent upstream (the error reads "no new quote was created from this card"), and a failure of that lookup is an error with nothing minted. The card's quote is then recorded with the settlement draft, and a failure there is reported like a failed draft registration ("could not be tracked"): a key that can be settled must be findable from its card. A card_id that is not a uuid is ignored, and the quote is simply not remembered
content, a sentence naming the quote and its real expiry, followed (when the quote carries an FX fee) by a sentence saying the fee is inside the sell amount and which amount settles:
Quote QT-88213 created: 1000.00 GBP -> 1187.40 USD at rate 1.18740. Expires at 2026-08-27T14:34:00+00:00 -- settle it before then, or ask for a fresh quote once it expires. The 2.50 GBP FX fee is included in the 1000.00 GBP sold; 1187.40 USD is the amount that settles.
structuredContent, flat, no data envelope:
FieldTypeDescription
quoteIdstringQuote identifier, required by settle_fx_quote
idempotencyKeystringMinted here, not an upstream field — the exact key settle_fx_quote requires
currencyPairstringSell/buy with a separator, e.g. GBP/USD — the V2 quote domain's format
fxRatestringThe quoted rate
fixedSidestringWhich side the amount was fixed on
buyAmountstringAmount bought
buyCurrencystringISO 4217
sellAmountstringAmount sold
sellCurrencystringISO 4217
fxFeesstringFX fee
createdAtstringNormalized to UTC ISO-8601 with an explicit offset
expiresAtstringThe real expiry, normalized the same way. The only expiry to trust; provider-set, so it may be far longer or shorter than the requested window (see above)
corporateCurrencyAccountstring, or nullThe resolved (or supplied) settlement account, or null if none could be resolved
corporateCurrencyAccountDisplaystring, or nullCurrency + account number or IBAN the portal shows (e.g. USD account 12345678, GBP IBAN GB33...); falls back to <currency> account (number unavailable) when the account lookup fails or yields no identifier. Null when no settlement account resolved. This is what the card's "Settle from" row renders; the raw reference never appears there
resellerFeestring, or nullEchoed from the request, or null
Full response adds nothing beyond the upstream envelope's message
field. The reverse direction matters too: idempotencyKey,
corporateCurrencyAccount, corporateCurrencyAccountDisplay and resellerFee are synthesized by this tool,
not upstream fields. They are absent from the full/raw payload and only
ever appear in the compact response.
Errors: a B2B API rejection (e.g. an invalid currency pair) surfaces as
a standard B2B API error. If the quote is minted upstream but the
server-side draft registration fails afterward (a store blip), this is
reported explicitly rather than as a bare failure:
Error: quote QT-88213 was created but could not be tracked -- it cannot be settled through this assistant. Do not report a usable quote to the user; ask for a fresh one via get_fx_quote.
With card_id, a card that already holds a live quote, is settled or
settling, or whose record was made for a different request is refused
before anything is sent upstream (the card then re-checks and shows its
real state):
Error: no new quote was created from this card -- it already holds a live quote, a conversion from it is already settled, a settlement of it may still be in progress, or it was made for a different request. Do not quote from this card again; the card shows its current state. For another conversion, call get_fx_quote to show a new card.
No other tool-local input validation beyond the standard schema checks, the
blank corporate_reference check and the capability refusal above.

settle_fx_quote#

Settle FX Quote · destructive, idempotent · touches an external system
Executes a previously created FX quote: moves real money between two of
the corporate's own currency accounts. Called by the FX quote card's own
Settle button after the user's explicit click; do not call this directly,
and never fabricate or reuse an idempotency key for a different quote. A
reused key returns the original conversion's outcome and ignores changed
arguments.
Hard-refused for any session that has not positively declared MCP UI
support.
A client that never declared support at initialize time also
will not see this tool in tools/list at all, but that omission is hygiene
only. The real enforcement is this check, which runs first, before any
upstream call:
Error: this session has not declared MCP UI support, so real-money FX settlement is refused here. Direct the user to the Sokin dashboard to execute this trade instead.
Unlike the two payment pairs (create_payment_instruction/submit_payment_instruction
and create_fx_payment_instruction/submit_fx_payment_instruction), this
refusal was NOT removed by the ARC-455 review. At the time FX settlement
executed instantly, with no B2B portal approval step behind it, so the
widget's own Settle click was the only human control this pair had. That is
no longer so (see "What the gate does not guarantee" below): the refusal now
backs up B2B's ARC-587 approval rule rather than standing alone, and
create_fx_quote still needs it so that no session mints real quotes with
no card to show them. server.py's UI_ONLY_TOOL_NAMES set names exactly
this pair plus create_fx_quote.
ParameterTypeRequiredDescription
idempotency_keystringyesThe key minted by create_fx_quote for this exact quote, its idempotencyKey field
corporate_currency_accountstringyesMust match the quote the user reviewed
quote_idstringyesMust match the quote the user reviewed
reseller_feestringnoMust match the quote the user reviewed
Arguments are checked against the draft create_fx_quote reserved under
idempotency_key. Any mismatch is refused, never silently corrected.
Draft lifetime. The draft create_fx_quote registers is held for 24
hours, then the key is refused as unknown and a fresh quote is needed. That
window is deliberately much longer than a quote actually lives, for two
reasons worth knowing as an integrator:
Quote expiry is not enforced by that TTL. How long a quote stays
valid is decided by the provider that priced it (see create_fx_quote),
so no local timer could get it right. Settling a quote whose window has
passed reaches a real synchronous pre-check and comes back as Error: this quote could not be settled: <reason>, the true reason, rather than
a misleading unknown-key refusal. Read the quote's own expiresAt, and
expect the expiry answer to come from the settle call.
A replay stays answerable. A settled key returns the original
conversion's outcome for as long as the draft record lives, so a retry
after a lost response is reported rather than mistaken for a new
settlement.
The card's memory. After a successful settlement this tool also
records, with no expiry, that the card the quote was made on was settled
(ARC-662). It finds the card from the key, so it takes no card id of its
own, and reads no user key: the card's user and request were stored with
the draft. The write is best-effort and never turns a settlement into an
error, and it is retried. A lost write is covered anyway: the card's attempt
record (below) names this key with no expiry, so the status check still
finds the settlement through the submitted record, a day later or a month
later, and create_fx_quote keeps refusing a new quote on the card.
Before the request is sent (after the validate pre-check passes) this
tool records the attempt on the card, with no expiry, and removes it only on
a definite rejection (a 4xx). So after any ambiguous end -- a 5xx, a
timeout, any other failure, or the request being cut off mid-POST -- the
card keeps reading settling instead of offering the conversion again (see
get_fx_quote's card status check). If the attempt cannot be recorded,
nothing is sent: the claim is released and the answer is Error: this quote could not be settled: the attempt could not be recorded, so nothing was sent. Try again in a moment. The record does not block a same-key retry:
B2B returns the original instruction request for a repeated key.
While a settlement is in flight, the key is claimed for up to 10 minutes: a
second call on the same key during that time is refused as in-flight
rather than executed, and a definite rejection releases the claim
immediately so a corrected retry does not have to wait it out. Submitted
records themselves are kept with no expiry, mirroring the backend's
permanent dedupe.
For an outcome check, prefer get_instruction_request over a repeat
settle_fx_quote call. It is a read, and it answers regardless of how
long ago the settlement happened.
Upstream deduplication. idempotencyKey is also sent on the wire, and
the transaction core dedupes on (idempotencyKey, instructionType, corporateCurrencyAccount). A replayed key returns the first request and
silently ignores changed values. That is the correctness layer; the gate
described here is an availability layer in front of it, which turns a
replay into an explicit, readable answer instead of a blind repeat.
What the gate does not guarantee. It constrains what can be submitted,
not who approved it. The model can hold a settleable key, so it can call
this tool directly rather than through the card's Settle button:
in create_fx_quote's reply, if the model calls that tool itself (MCP
has no widget-only channel through which the card could hold a secret);
since ARC-662, in get_fx_quote(card_id=)'s quoted reply. That call
returns the live key, the quote id and the account in a normal, stored
tool result, and the server cannot tell the card asking from the model
asking. Before ARC-662 the key lived only in the card and in the card's own
replies, which never enter the transcript, so the Settle click was the
only path to a settlement. That is no longer true.
What stands between a model-initiated call and a conversion:
the arguments must byte-match the quote the card registered;
the session must have positively declared MCP UI support (the hard
refusal above);
the host's own confirmation prompt for destructive tools, which is
load-bearing, not decorative;
the real human control now: B2B's ARC-587 approval rule
(sokin-b2b-api-service#4435). It is seeded for channel == MCP and
instruction type FX, so an MCP-originated settlement is created but waits
for approval in the Sokin portal instead of executing; the card shows
"Needs approval". Its edges:
it fires only while B2B resolves our token's client id as the MCP
channel. During the ARC-650/ARC-651 auth cutover a client-id mismatch
resolves UNKNOWN, which is deliberately not gated, so ARC-651 must
land in step with the token change;
the seeded rule is per corporate and can be deactivated.
This tool creates and submits an instruction request; it never approves or
completes one.
content on success:
FX conversion submitted: instruction request IR-FX-9911 for account CCA-1002. This is a submission receipt, not confirmation the conversion completed -- check get_instruction_request for its actual outcome.
structuredContent, a data envelope, the same shape the upstream
create-instruction-request call returns:
FieldTypeDescription
data.externalReferencestringThe instruction request reference. Pass to get_instruction_request for its actual outcome
data.statusstringInitial status, e.g. "Created"
Full response adds the upstream envelope's message field back.
Tool-specific errors:
ConditionMessage
Session not MCP-UI-capableError: this session has not declared MCP UI support, so real-money FX settlement is refused here. Direct the user to the Sokin dashboard to execute this trade instead.
Empty idempotency_keyError: idempotency_key must not be empty.
Unknown key (never registered, or expired)Error: unknown idempotency key -- quotes are prepared via create_fx_quote, which mints the key and renders the review card. If a quote was lost, ask for a fresh one via get_fx_quote.
Arguments do not match the reserved draftError: these arguments do not match the quote the user reviewed under this idempotency key. Only the exact reviewed quote can be settled -- for a different conversion, get a fresh quote.
Another settlement for the same key is already in flightError: another settlement attempt for this exact quote is already being processed -- its outcome is not yet known. Do not retry immediately; check get_instruction_request shortly, or wait a moment before trying again.
Same key already settledNot an error: returns the original get_instruction_request result again, prefixed "This quote was already submitted, no new conversion was created."
Quote fails a synchronous pre-check (e.g. expired)Error: this quote could not be settled: <reason>. Adds Ask for a fresh quote via get_fx_quote. when the failure category is the quote itself
B2B API rejects with 4xxStandard B2B API error shape. The reservation is released, so a corrected retry does not have to wait out its TTL
B2B API fails with 5xx, or times out, or the reply to the create call cannot be readError: the settlement submission's outcome is unknown (API error <status>: <message>, or "the response could not be read") -- it may or may not have been accepted. Do not report this conversion as settled or as failed, and do not retry immediately; check the account's recent instruction requests first. The reservation is deliberately kept, not released, so a blind retry cannot double-submit, and the card's attempt record (written before the request) stays, so the card keeps reading settling after the reservation lapses

create_fx_payment_instruction#

Create FX Payment Instruction · read-only · touches an external system
Prepares an FX payment for a human to review. It submits nothing.
Despite the name, no payment exists after this call and no money has
moved. Converts and pays a beneficiary in one motion against a quote this
tool mints for you, via POST /instruction-requests/fx-payment on submit.
This follows the same prepare/submit-with-draft-gate pattern as
create_payment_instruction, combining the account/beneficiary side of a
payment with the quote side of an FX conversion.
This tool no longer takes a quote_id. Earlier it required a live
quote minted beforehand by a separate create_fx_quote call. Per Vuk's
review of ARC-455 (finding #1), that shape is gone: there was no reliable
way for the calling model to receive a quote_id minted by an earlier
create_fx_quote call back into its own context (that depends on
ui/update-model-context reaching it, which is not guaranteed for a
non-UI client), so the two-call flow just looped back on get_fx_quote.
Instead this tool now takes the raw pricing inputs directly (buy_currency,
sell_currency, amount, fixed_side, corporate_reference) and mints a
real, live quote itself, internally, via the same FX quote adapter
create_fx_quote uses (POST /fx/quotes/, the V2 quote domain). The B2B
API does not care who mints a quote or when -- it only checks
existence/expiry/corporate/sell-currency at instruction time -- so minting
it here, with the beneficiary passed straight through as the quote's
beneficiaryExternalReference, is equivalent to the old two-call flow and
removes that failure mode entirely.
Minting the quote here is a genuine write, exactly like create_fx_quote:
every call to this tool persists a real quote and calls a live external
rate provider. Never call it speculatively or more than once for the same
request. Call it directly, with no get_fx_quote step first, whenever the
user wants to pay an external beneficiary with a conversion, and only once
they are ready to actually prepare the payment; the figures it returns (fees,
the amount the beneficiary receives) are what the user reviews. get_fx_quote
solves a different problem, converting between the corporate's own accounts,
and on a non-UI client it shows no rate at all: it returns the request echo
plus a link to the Sokin portal, which is the path for a non-UI own-account
conversion.
What it actually does, in order: mints the quote as described above; runs
the FX-payment validate dry run (the same instructionType: FXPayment the
real create uses, carrying the account, beneficiary and the freshly-minted
quote) so the review can show server-computed fees and catch an expired
quote, an unusable beneficiary or an insufficient balance in one round
trip; resolves the source account's currency with one read-only account
lookup; mints an idempotency key server-side; and registers the normalized
draft against that key. When the calling session has declared MCP UI
support it also renders its own MCP UI embed
(ui://sokin-mcp/fx-payment-review), a review card with Approve and
Cancel. Only the user's Approve click there submits, by invoking
submit_fx_payment_instruction from inside the widget. This tool renders
its own interactive card when one is available, so it should not be
followed with the visualize tool.
A closer look at the read-only badge. The tool is annotated
readOnlyHint: true, and its own source comment justifies that by saying
its "only upstream call is the validate dry-run" -- the same reasoning
create_payment_instruction uses. That comment predates the quote-mint
step above and is no longer accurate: this tool makes three upstream calls,
not one -- the FX quote mint (a genuine write: a persisted quote record and
a real call to an external rate provider, upstream of anything a human has
approved), the FX-payment validate dry run (side-effect-free), and the
account currency lookup (side-effect-free). create_fx_quote itself is
annotated readOnlyHint: false for exactly this kind of upstream write;
this tool performs the identical write internally while staying on the
read-only default. In practice this means calling create_fx_payment_instruction
always has a real, non-idempotent upstream side effect -- a live quote and
a persisted quote record -- even on a call whose dry run then fails and
produces no approvable draft at all. Treat it accordingly: never call this
speculatively, the same discipline create_fx_quote already asks for.
Its own draft-registration write is unaffected by this and remains gate
bookkeeping, not a business write against the corporate's money, same as
create_payment_instruction's.
The dry run declares the same instruction type as the real create, so the
beneficiary is checked before the user is asked to approve: that it
exists, that it is not an internal beneficiary (which this instruction
type does not accept), that it can receive the quote's buy currency, and
that it passes verification of payee. A beneficiary problem is therefore
reported by this tool rather than surfacing after the Approve click.
One thing this tool still cannot guarantee is that the quote survives to
settlement. The backend re-checks quote validity at the moment a human
approves the instruction in the Sokin portal, which is a human-paced wait,
so a quote valid here can still expire before then. That rejection arrives
through the instruction's status (Rejected, with a failure reason), not
as an error from either tool in this pair.
MCP UI support changes only which text this tool shows you, not whether
it will run
-- the identical reasoning create_payment_instruction
documents at length above. In short: no capability check refuses this
tool any more (ARC-455 finding #2), the session's declared capability only
selects the wording (widget-flow text vs. an idempotency key spelled out
for chat confirmation), and structuredContent carries idempotencyKey
either way.
ParameterTypeRequiredDescription
corporate_currency_accountstringyesSource account reference from list_accounts
beneficiary_referencestringyesThe payee, from list_beneficiaries. Must already exist — if it doesn't, create it first with create_beneficiary (needs a one-time emailed code). Passed to the quote provider (as beneficiaryExternalReference) as well as the payment itself
buy_currencystringyesISO 4217 code being bought (e.g. "USD")
sell_currencystringyesISO 4217 code being sold (e.g. "GBP")
amountstringyesThe amount of whichever side fixed_side names
fixed_side"BUY" or "SELL"noWhich side amount refers to. Default "SELL"
corporate_referencestringnoThe corporate to quote for. Required in practice; call list_corporates first if the user hasn't named one. Omitting it does not auto-resolve for a typical caller -- the upstream API only fills it in when the token itself already identifies one specific corporate (a corporate's own self-service token, or a reseller token delegating as that one corporate), never for a plain reseller token. Used only for minting the quote, never sent to the payment itself
reseller_feestringnoPlain decimal string, zero or greater
sender_referencestringnoShown to the recipient, max 18 characters
payment_purposeenumnoSame 22 declared values as create_payment_instruction; see the enums appendix. An unrecognized value is rejected by schema validation, not sent
status_check_keystringnoLeave unset. Only the review widget passes it, with the idempotency key it was given, to ask whether its draft was already submitted. With it the tool mints no quote and makes one read-only call, for the account; see "Status check" below
content, when MCP UI is declared -- a not-submitted notice:
FX payment prepared and validated -- awaiting the user's confirmation in the review widget. Nothing has been submitted; do not report this payment as made, and do not call submit_fx_payment_instruction yourself.
content, when MCP UI is not declared (or unknown) -- unlike
create_payment_instruction, submit's arguments here are NOT the same as
create's (submit needs quote_id, which is not one of create's own
parameters), so the idempotency key, the quote_id, and the real
server-computed fee numbers are all spelled out in the text -- none of it
can rely on structuredContent, which a non-UI client is not guaranteed to
show the model:
FX payment prepared and validated -- nothing has been submitted yet. Total debit 100.00 (0.50 Sokin fee, 0.00 reseller fee); beneficiary would receive 125.00 (GBP debited, beneficiary receives USD). idempotency key: 9c21ab4f-... . quote_id: FXQ-1a2b3c... . Read these figures back to the user and get their explicit approval. Only after they approve, call submit_fx_payment_instruction with this idempotency_key, this quote_id, and the same corporate_currency_account, beneficiary_reference, reseller_fee, sender_reference, and payment_purpose you used here -- it does not take buy_currency, sell_currency, amount, fixed_side, or corporate_reference; those only mint the quote. Never submit without that approval, and never tell the user the payment was made until submit_fx_payment_instruction succeeds -- it will still need approval in the Sokin portal afterward.
When the underlying dry run fails outright (either branch, same text):
The FX payment could not be validated: <reason>. Nothing has been submitted.
When the quote itself has expired or is unknown by the time the dry run
runs (the validate response's errorCategory is "quote" -- a defensive
branch, since the quote was only just minted by this same call, not the
expected path):
Error: the quote minted for this request is no longer valid (<reason>). Call create_fx_payment_instruction again to mint a fresh one. Nothing has been submitted.
Despite the leading word Error:, none of the three texts above set
isError: true -- they are reports from the validation dry run, delivered
through the normal (non-error) result channel, exactly like
create_payment_instruction's own validation-failure text. See the top-level
Errors section's note on this.
structuredContent: this tool builds its own payload, no
{"data": ...} envelope:
FieldTypeDescription
draftobjectThe normalized FX payment exactly as it would be submitted: corporate_currency_account, beneficiary_reference, quote_id, reseller_fee, sender_reference, payment_purpose. No amount, buy_currency, sell_currency, or fixed_side field -- the ledger legs derive entirely from the quote, and the pricing inputs exist only to mint that quote, never to submit the payment. quote_id here is the id of the quote THIS call minted for you, not one you supplied -- pass it through unchanged to submit_fx_payment_instruction
currencystring, or nullThe source account's currency, for the card's debit and fee rows. Null when the account lookup failed; display only, it never blocks a valid draft
buyCurrencystringThe beneficiary's currency (this call's own buy_currency parameter, normalized), labelling what the beneficiary actually receives. Always present, unlike currency -- there is no lookup that can fail
quoteExpiresAtstring, or nullThe minted quote's normalized expiry timestamp, straight from the adapter -- drives the card's countdown and disables Approve once it passes. Null only if the upstream response omitted it
quoteCreatedAtstring, or nullThe minted quote's normalized creation timestamp, paired with quoteExpiresAt for the countdown's total span
validationobjectThe FX-payment validate dry-run verdict (instructionType: FXPayment), same envelope shape as validate_payment_instruction's, including a resolved feeBreakdown
idempotencyKeystring, or nullServer-minted, registered to this exact draft. Null when validation failed
duplicateWarningobjectPresent only when an identical draft was already submitted: instructionRequestReference and submittedAt
quoteRequestobjectThe pricing arguments this draft was prepared from, under the tool's own parameter names: buy_currency, sell_currency, amount, fixed_side, corporate_reference. Absent on the validation-failure branch. The review card needs these to rebuild this tool's required arguments for its status check
duplicateWarning is not an error, with the same semantics as
create_payment_instruction's: when a widget is present it renders an
already-submitted card with no Approve button either way, and a deliberate
second identical payment needs a distinguishing sender_reference.
Full response adds: nothing. This tool assembles its own payload.
Errors: input validation (blank corporate_currency_account or
beneficiary_reference, a malformed reseller_fee, an over-length
sender_reference) is checked and rejected with isError: true before
the quote is minted, deliberately -- a blank reference or a malformed fee
must never burn a real, provider-priced quote's validity window for
nothing. A B2B API rejection while minting the quote itself (an invalid
currency pair, for instance) surfaces as a standard B2B API error. Unlike
create_fx_quote, a failure registering the draft after the quote has
already been minted (a store blip) is not given its own explicit message
here -- it falls through to the generic unexpected-failure fallback, so a
real, live, unexpired quote can in that rare case be reported to the caller
as an opaque failure with no reference to hold onto.
Draft lifetime: pending drafts expire after 24 hours; submitted records
are kept with no expiry, mirroring the backend's permanent dedupe. This
draft has its own namespace in the server's session store, separate from a
plain payment's draft, so an FX payment and a plain payment can never
collide on the same idempotency key.
Status check (status_check_key). The same mechanism as
create_payment_instruction's, with the same response fields and states.
It is answered before the quote is minted, so reopening a conversation
costs no provider quote on a host that replays the stored result. (A host
that remounts the card without it makes the card fall back to a full
re-prepare, which does mint one; that fallback predates the status check
and is ARC-663.) The card rebuilds this tool's arguments from
the replayed draft (without quote_id) plus quoteRequest, and the
server matches them against the draft's duplicate identity. That identity
leaves out quote_id, because every prepare mints a fresh one and two
prepares of the same payment would otherwise never match. A card
prepared before quoteRequest existed cannot rebuild those arguments; it
renders as it always did, and its long-expired quote keeps Approve
disabled.

submit_fx_payment_instruction#

Submit FX Payment Instruction · destructive, idempotent · touches an
external system
Submits a prepared FX payment. When the calling session declared MCP UI
support, this tool is invoked by the FX payment review widget after the
user's explicit Approve, and is not meant to be called directly in that
case. When it did not, create_fx_payment_instruction's own text hands
back the idempotency key and instructs the calling model to get the user's
explicit approval in chat first -- calling this tool directly at that point
is the intended path, not a bypass; there is no separate capability check
here that would refuse it. Either way, always call
create_fx_payment_instruction first -- it mints the quote and the key
this tool requires, and renders the widget when one is available.
Moves real money: converts and pays a beneficiary in one motion via POST /instruction-requests/fx-payment, and cannot be cancelled once submitted.
Only accepts an idempotency_key minted by create_fx_payment_instruction,
and only with arguments identical to the draft the user reviewed. A reused
key with the same draft returns the original payment's state; a reused key
with different values is refused.
ParameterTypeRequiredDescription
idempotency_keystringyesThe key minted by create_fx_payment_instruction for this exact draft
corporate_currency_accountstringyesMust match the reviewed draft
beneficiary_referencestringyesMust match the reviewed draft
quote_idstringyesMust match the reviewed draft -- this is the id of the quote create_fx_payment_instruction minted for you, from its draft.quote_id field, not a value you choose
reseller_feestringnoMust match the reviewed draft
sender_referencestringnoMust match the reviewed draft
payment_purposeenumnoMust match the reviewed draft
content on success:
FX payment submitted: instruction request IR-FXPayment-77 from CCA-1 to BEN-1. This is an instruction-request reference, not a completed payment -- it now needs human approval in the Sokin portal before it is processed. Check the outcome via get_instruction_request.
The approval clause is stated unconditionally on purpose, even though
ARC-587's rule has edges (deactivatable, and an unresolved calling channel is
not gated -- see settle_fx_quote's "What the gate does not guarantee"
section): this text errs toward over-warning rather than under. The review
card, when one is present, tracks the instruction's actual progress through
that approval step and renders it once the backend reports it; a non-UI
client gets the same information from get_instruction_request.
structuredContent, {"data": {"externalReference": ...}} on a fresh
submission (the same single-field shape submit_payment_instruction
returns, from the create endpoint's own response model, which carries only
externalReference); on a replay of an already-submitted draft, the same
get_instruction_request shape (externalReference, status,
createdInstructionReference, failureReason, errorCategory) instead of
re-posting.
Full response adds: the envelope's message field on a fresh
submission; nothing further on the replay branch, which already passes
message through in compact mode.
A malformed upstream response is a hard error, the same discipline as
submit_payment_instruction: a 202 whose body lacks externalReference
is rejected as a synthesized 502 reading "API returned an unexpected
response shape for the FX payment submission -- whether it was accepted is
unknown; check get_instruction_request before retrying." rather than being
rendered as a false success.
Tool-specific errors: the same draft-gate outcome set
submit_payment_instruction lists (unknown key, submitted-draft mismatch,
pending-draft mismatch, duplicate blocked in both its in-flight and
already-submitted variants), over this pair's own draft namespace, plus the
same pre-POST client-acquisition release guard: a failure acquiring the
upstream client between the gate's reservation and the real POST is a
definite non-submission and releases the reservation, gated on the outcome
having actually been a clear-to-submit verdict so a stray release can never
delete a live duplicate block for a payment that already exists.

list_beneficiaries#

List Beneficiaries · read-only · touches an external system
Searches and browses existing payees. Its primary use is checking whether a
payee already exists before creating one with create_beneficiary — the API
has no duplicate detection on create, and a payee's displayName, entity
type, country, and address are immutable afterwards (only destination and
identity details are updatable via update_beneficiary; there is no delete
tool), so this check is what stands between the user and a duplicate payee.
ParameterTypeRequiredDescription
corporate_referencestringnoThe corporate to list for. Required in practice
searchstringnoText to match against the display name. Omit to list all
search_mode"contains" or "starts_with"noHow search matches. Default "contains"
limitintegernoMax per page
next_tokenstringnoCursor from the previous page
pageintegernoNot supported; accepted only to reject it clearly
offsetintegernoNot supported; accepted only to reject it clearly
content, a markdown table:
2 beneficiaries:
ReferenceNameTypeCountryCurrencies (rails)Status
BEN-3301Fabrikam GmbHcompanyDEEUR (sepa/swift)active
BEN-3302Jane OkaforindividualGBGBP (faster_payments)active
structuredContent, envelope {"data": [...], "pagination": {...}},
each item:
FieldTypeDescription
externalReferencestringThe beneficiary reference
displayNamestringDisplay name
entityTypestringindividual or company
countryCodestringISO country code
destinationsarrayEach entry {currencyCode, paymentRail}
statusstringactive or deleted
Each distinct (currency, rail) pair is kept rather than deduped to a bare
currency list. A beneficiary reachable in GBP over both faster_payments
and swift differs in settlement speed and cost, and collapsing that would
lose the distinction.
Full response adds: isInternal, supportedRails, createdAt,
version, the individual / company detail objects, address, and the
complete destination payloads — routing codes, account numbers, financial
institution details, validationStatus, autodepositEnrolled. It also adds
the envelope's message field (the literal "Success."). One field is
withheld even here: the e-Transfer securityAnswer (the answer a recipient
must give to claim a transfer — credential-shaped, so never returned).
Validation errors:
Error: page/offset pagination is not supported here. Use limit and next_token instead.

get_beneficiary#

Get Beneficiary · read-only · touches an external system
One payee's full details by beneficiary id. Where list_beneficiaries
collapses each payee to its (currency, rail) pairs, this returns every
destination's actual account details — account numbers, routing/institution
data, e-Transfer email, crypto wallet address, or target corporate reference —
plus each destination's validationStatus.
ParameterTypeRequiredDescription
beneficiary_idstringyesThe payee's externalReference from list_beneficiaries
corporate_referencestringnoThe corporate this payee belongs to. Required in practice
content — a header line, a destinations table, and the creation date:
Acme Ltd -- company, GB, active
CurrencyRailValidationAccount
GBPfaster_paymentsmatched12345678
CADe_transfernot_validatedpayee@example.com
Created 2026-01-01T00:00:00Z.
A payee with status deleted gets an explicit trailing line: it cannot
receive payments.
structuredContent — envelope {"data": {...}}:
FieldTypeDescription
externalReferencestringThe beneficiary reference
displayNamestringDisplay name
entityTypestringindividual or company
countryCodestringISO country code
statusstringactive or deleted
isInternalbooleanWhether this is a Sokin-to-Sokin payee
destinationsarrayFull per-method payloads: currencyCode, paymentRail, validationStatus, and the method's own account fields. Bank-account destinations carry their stored snake_case keys (e.g. account_number, institution); e-Transfer and crypto destinations use camelCase
individual / companyobjectIdentity details — only the one matching entityType, and only when set
addressobjectPayee address, when set
createdAtstringCreation timestamp
Two upstream fields never reach the compact response: supportedRails,
because the details endpoint always returns it as [] (rail availability
lives in destinations[].paymentRail instead), and version, an internal
concurrency counter. For an e-Transfer destination without auto-deposit the
security question is returned but the security answer never is — it
is what the recipient must give to claim the transfer.
Full response adds: the envelope's message, supportedRails (always
empty here), version, and the raw destination payloads — minus the
e-Transfer securityAnswer, which is withheld even from the full response
(credential-shaped: it is the answer a recipient must give to claim a
transfer).
Validation errors:
Error: beneficiary_id must not be empty.
That reference is not available on this account. Check BOTH beneficiary_id (the Reference column from list_beneficiaries) and corporate_reference (the value list_corporates returned), and retry. If the reference is correct, this account may not have access to it. — one rendering for every reference failure: an unknown payee, an unknown corporate, a corporate belonging to someone else, or a permission denial. It names both candidate arguments and never which one upstream rejected. This tool used to tell an unknown corporate apart from an unknown payee by matching the phrase "corporate not found" in the upstream text; that was removed because upstream 404s a corporate reference that does not exist but 403s one that exists and is foreign, so the distinction answered "does that corporate exist".

get_beneficiary_schema#

Get Beneficiary Schema · read-only · touches an external system
The fields a new payee needs for a given payment method, currency, and country
— names, types, requiredness, and allowed values — for collecting details
before creating a payee. Field names are dot-paths into the beneficiary
payload (e.g. address.city, bankAccount.accountNumber).
Payment method here is a different taxonomy from the payment rails
shown in list_beneficiaries destinations. Every rail belongs to exactly one
method: e_transfer → e_transfer, internal → sokin_internal, pix →
virtual_account, the on-chain networks (base, tron, ethereum,
polygon, solana) → crypto, and every other rail (faster_payments,
sepa, swift, ach, eft, …) → bank_account.
ParameterTypeRequiredDescription
payment_method"bank_account" | "e_transfer" | "virtual_account" | "sokin_internal"yesSee the rail-to-method mapping above. The taxonomy's fifth method, crypto, is deliberately not an accepted value — the upstream endpoint cannot describe crypto payees yet (its currency validation caps currencyCode at 3 characters; the only crypto currencies are the 4-character stablecoin codes USDC/USDT), so there is no schema to fetch. Crypto payees skip the schema lookup: currency_code USDC/USDT, payment_rail = the on-chain network, wallet address in the crypto object's walletAddress
currency_codestringunless sokin_internalISO 4217 currency the payee is paid in
country_codestringunless sokin_internalISO 3166-1 alpha-2 country of the payee's account
entity_type"individual" | "company"unless sokin_internalPayee entity type
autodeposit_enrolled"true" | "false" | "unknown"noe-Transfer only: whether the recipient email has Interac auto-deposit. Passing "false" or "unknown" makes the security question/answer fields required; omitting the parameter behaves like "true" (they stay optional), so pass "unknown" rather than omitting when enrolment hasn't been checked
corporate_referencestringnoThe corporate the payee will be created under. Required in practice
content — a markdown table:
Beneficiary schema for bank_account / GBP / GB / company -- 4 fields:
FieldTypeRequiredNotes
displayNamestringyeslength 1-100
bankAccount.accountNumberstringonly for faster_payments
entityTypeenumyesone of: individual, company
eTransfer.securityQuestionstringnoconditional on eTransfer.email; fixed value: Confirm the agreed passphrase
A field marked "only for rails" is required just for those payment rails
(whether upstream expressed that via requiredForPaymentRails or via
rail-gating conditions, as with bankAccount.institution.branchCode on
SWIFT-plus-local corridors); a field marked "conditional on X" only applies
once X is answered. On a multi-rail corridor, an enum value noted
"value only with rails" is accepted just for those rails — e.g. on the
three-rail GBP/GB corridor, bankAccount.accountNumberType renders
one of: account_number, iban; account_number only with chaps, faster_payments, swift; iban only with swift, so a faster_payments payee
must use account_number. Single-rail corridors carry no conditions and
render without the extra notes.
structuredContent — envelope {"data": {"condition": {...}, "fields": [...]}}.
condition echoes the resolved query context (currencyCode, entityType,
countryCode, paymentMethod). Each field object always carries name,
fieldType (string / integer / boolean / date / enum), and
isRequired (a non-nullable boolean upstream, default true — never absent),
plus — only when set, the upstream route excludes nulls — min, max,
allowedValues, requiredForPaymentRails, conditions, visibility,
conditionalOn, revalidateOn, fixedValue.
Full response adds: the envelope's message, and regional — which the
upstream processor never populates on this endpoint today.
Validation errors:
Error: currency_code, country_code, entity_type required for payment_method 'bank_account' (only sokin_internal can omit them). — mirrors the API's own rule; for sokin_internal the API defaults the entity type to company.
A payment_method outside the four accepted values (including crypto — see the parameter table) fails MCP argument-schema validation before the tool body runs.
That reference is not available on this account. Check corporate_reference (the value list_corporates returned), and retry. If the reference is correct, this account may not have access to it. — corporate resolution runs upstream before the schema lookup, and corporate_reference is this tool's only reference argument, so the neutral rendering can name it without disclosing which failure it was.
A combination the corporate has no payment rails for is rejected upstream:
API error (400): No payment rails for GBP+US and bank_account.

validate_beneficiary#

Validate Beneficiary · writes (non-destructive, idempotent) · touches an external system
Verifies payee account details before creating a payee, probes an e-Transfer
email's auto-deposit enrolment, or re-checks an existing payee's
destinations. create_beneficiary's docstring requires the applicable
pre-create mode; the existing-payee mode is also what makes a fresh (or
just-patched) EUR/SEPA payee payable — the API blocks payments to
EUR/SEPA destinations whose stored status is anything other than
matched/close_match/check_not_possible (not_validated, no_match,
and invalid all block). Exactly one verification mode per call:
ModeFieldsWhat runs
Existing payeebeneficiary_idRe-validates every destination and persists each refreshed validationStatus — the reason this tool is not read-only. Convergent: re-running re-derives the same statuses. Corridor-gated per destination: e-Transfer gets a DDR enrolment probe, internal and non-EUR bank destinations come back not_validated ("not applicable"), GBP local rails get CoP where reseller-enabled, EUR gets VoP
e-Transfer probeemailInterac auto-deposit (DDR) check. No persistence
IBAN name checkiban + bic + account_nameVerification of Payee — runs on any format-valid IBAN regardless of country; verdicts are matched / close_match / no_match / check_not_possible, never not_validated in this mode. A malformed IBAN or BIC comes back invalid
UK CoPsort_code + account_number + account_nameConfirmation of Payee on GBP local rails — only where enabled for the reseller; otherwise not_validated
ParameterTypeRequiredDescription
beneficiary_idstringmode 1The payee's externalReference from list_beneficiaries
emailstringmode 2Recipient email for the auto-deposit check
ibanstringmode 3IBAN (15–34 chars)
bicstringmode 3BIC/SWIFT, 8 or 11 chars (upstream uppercases)
account_namestringmodes 3, 4The account holder name to verify
sort_codestringmode 4UK sort code, 6 digits — spaces/hyphens tolerated
account_numberstringmode 4UK account number, 8 digits
corporate_referencestringnoThe corporate to validate under. Required in practice
content — a worst-status-first summary line and a results table:
Close match only -- the name on record differs from the name supplied. Show the user both names and get their confirmation before going further.
CurrencyRailCheckStatusName on recordDetails
COPclose_matchJ Okafor LtdName is a close match
The summary wording is load-bearing: no_match/invalid render as NOT
verified
; check_not_possible/not_validated render as NOT a pass (the
check did not run). A multi-check response's headline is prefixed
Worst of N checks: so a mixed payee never reads as "nothing ran". A matched
that includes a DDR result renders All checks passed. with a note that DDR
confirms auto-deposit enrolment, not the account name. Email-mode
responses render the enrolment result instead —
enrolled yes/no/unknown, registered/legal names when enrolled, whether
security Q&A is required, the matching autodeposit_enrolled value to pass to
get_beneficiary_schema, and that enrolment state's e-Transfer field list.
structuredContent — envelope {"data": {...}}:
FieldTypeDescription
isValidbooleanFalse only when some result is no_match or invalid. True does not mean verified — check_not_possible and not_validated still yield true
resultsarrayOne entry per check: validationType (FORMAT/VOP/COP/DDR), status, and — when set — currencyCode, paymentRail, details, actualName (the name on record, on a CoP partial/close match)
autodepositEnrolledbooleanEmail mode only; absent when the check was inconclusive
registrationName / legalNamestringEmail mode, only when enrolled
securityQaRequiredbooleanEmail mode: whether the payee needs a security question/answer
fieldsarrayEmail mode: the e-Transfer schema fields for that enrolment state (same shape as get_beneficiary_schema)
Full response adds: the envelope's message field only.
Validation errors:
Error: supply fields for exactly one verification mode: beneficiary_id (existing payee); email (e-Transfer auto-deposit); iban + bic + account_name (IBAN name check); or sort_code + account_number + account_name (UK Confirmation of Payee).
Error: the IBAN name check needs all of iban, bic and account_name.
Error: the UK Confirmation of Payee check needs all of sort_code, account_number and account_name.
No payee with id 'ben_missing' exists under this corporate -- check BOTH beneficiary_id ... and corporate_reference ... — this tool keeps the corporate-vs-id 404 split that get_beneficiary dropped (see ARC-610 in the decision notes); its corporate branch now renders a fixed Corporate not found string rather than quoting the upstream message.
A 200 whose payload lacks results/isValid is reported as
API error (502) — "the result is unknown; do not report the details as
valid or invalid" — rather than letting a gateway artifact read as a verdict.

create_beneficiary#

Create Beneficiary · writes (additive, NOT idempotent) · touches an external system
Creates a new payee, protected by an emailed one-time confirmation code
(OTP): one tool, called twice. The first call (no otp_code) dry-runs
the draft against POST /beneficiaries/batch-validation — an invalid draft
costs nothing, no email is sent — then attempts the create; if the corporate
requires OTP, the API emails the user a 6-character code and the tool
returns status otp_required. The second call repeats the same arguments
plus otp_code. For a corporate without the OTP requirement, the first call
creates immediately. Calling again without otp_code while any code is
outstanding for this user and action type — a resend of the same draft OR a
corrected draft after a typo'd one — returns status otp_resent instead:
upstream keeps ONE live code per user and action type and overwrites it on
every issue, so the text says any earlier code is void and to use the MOST
RECENT email.
There is no upstream duplicate detection on create: a repeated call
creates a second payee. Check list_beneficiaries first; the tool
deliberately adds no duplicate blocking of its own (decided 2026-09-11 —
duplicates are B2B's/the user's domain), only a 10-minute in-flight claim so
the same draft can't be POSTed twice concurrently.
The docstring requires a validate_beneficiary name check before
creating wherever the destination has one (IBAN → VoP, UK sort code → CoP,
e-Transfer → the email/auto-deposit probe; destinations with none: internal,
pix, crypto, and bank accounts with neither an IBAN nor UK sort-code details
— e.g. US ACH, CAD EFT), with a hard stop on
no_match/invalid/close_match until the user has seen both names. This is instruction to the calling model, not
server-side enforcement (decided 2026-09-14 — the checks stay in
validate_beneficiary rather than being embedded in the create flow). It
also instructs the model to run the beneficiary_id mode after creating
an EUR/SEPA payee: a new payee starts not_validated, and the API blocks
payments to EUR/SEPA destinations until a validation refreshes the stored
status.
The two-call state is held server-side keyed by user and draft fingerprint
(tools/beneficiary_otp_gate.py): the redeem call must be byte-identical
to the trigger call (including corporate_reference), or it is refused
before any network call. That byte-match is protection the API itself lacks —
upstream codes are scoped per user+action, not per payload. The gate also
tracks the newest trigger per (user, action type), mirroring upstream's
one-live-code rule: triggering a second draft of the same type supersedes
the first, whose redeem is then refused with an instruction to re-trigger —
without this, an older draft could silently spend the newer draft's code.
OTP mechanics (upstream): codes are 6 characters, single-use (consumed even
when the create then fails for another reason), expire after 10 minutes, max
5 emails per 15 minutes, and 5 wrong attempts temporarily blocks the
user's account including their Sokin portal login
— the tool's error
wording forbids the model from guessing or retrying codes. Upstream keeps
one live code per user per action type (internal Sokin-to-Sokin payees
are tracked separately from all other rails): every new code email replaces
the previous code of the same type, so only the most recent email works —
including across different pending drafts of that type.
ParameterTypeRequiredDescription
display_namestringyesPayee display name (1–255 chars)
currency_codestringyesISO 4217; for crypto rails, the stablecoin code (USDC/USDT)
payment_railstringyese.g. faster_payments, sepa, swift, eft, e_transfer, internal, pix, tron. Corridor legality is checked upstream
entity_type"individual" | "company"unless rail internalPayee entity type
country_codestringunless rail internalISO 3166-1 alpha-2
bank_account / e_transfer / virtual_account / sokin_internal / cryptoobjectexactly oneThe destination object matching the rail's payment method (rail→method mapping as in get_beneficiary_schema); inner keys are the camelCase names the schema returns
individual / companyobjectper entity_typeIdentity details
addressobjectbank rails + e_transferNot required for internal, pix, or crypto rails
corporate_referencestringnoCorporate to create under. Required in practice; part of the byte-match
otp_codestring2nd callThe 6-character code from the user's email
confirm_verified_payee_namebooleannoOnly after a VoP mismatch the user explicitly confirmed; changes the draft, so the flow restarts and needs a fresh code
content / structuredContent — three shapes:
Draft invalid (informational, isError false): {"status": "draft_invalid", "errors": [{"field", "message"}]} — text lists each field error and says nothing was created and no code email was sent.
Code triggered (informational, isError false): {"status": "otp_required", "expiresInSeconds": 600} — text instructs asking the user for the code and calling again with the same arguments plus otp_code. Any trigger while a code is outstanding for this user and action type (same draft or a different one) returns {"status": "otp_resent", ...} — text says a NEW code was emailed, any earlier code is now void, and to use the most recent email.
Ambiguous upstream failure (isError true): any 5xx/transport error renders API error (N): <message> -- the request may still have gone through, so whether the payee was created is UNKNOWN. Do NOT retry blindly: check list_beneficiaries for this payee FIRST -- a blind retry can create a DUPLICATE. ... — the in-flight claim is kept, so an identical call is refused for up to 10 minutes.
Created: envelope {"data": {...}} with externalReference, displayName, status, destinations[] (currencyCode, paymentRail, validationStatus), createdAt:
Payee created: Acme Ltd (reference ben_new, status active).
Destinations: GBP via faster_payments (name verified).
(supportedRails and version are dropped as in get_beneficiary; a
create without OTP appends "No confirmation code was required for this
corporate.")
Full response adds: on the created shape, the envelope's message plus
the full raw record (supportedRails, version, complete destination
payloads — minus the e-Transfer securityAnswer, withheld even here); on
the draft-invalid shape, the raw batch-validation envelope.
Validation errors (each refuses before the create is attempted):
Error: display_name must not be empty. (and the same for currency_code / payment_rail)
Error: payment_rail 'sokin_internal' is a payment METHOD, not a rail. ... — catches the method-vs-rail taxonomy confusion
Error: payment_rail 'faster_payments' takes exactly one destination object: bank_account (you supplied: e_transfer). Call get_beneficiary_schema for that corridor's field list.
Error: entity_type and country_code are required for every payment rail except internal.
Error: otp_code must be the 6-character code from the confirmation email (letters and digits). ...
Error: these arguments don't match the draft that triggered the code (or more than 10 minutes have passed). ... — the byte-match refusal
Error: a newer confirmation code was requested after this draft's -- each new code email replaces the previous one of the same type, so the code for this draft is no longer redeemable. ... — the superseded-trigger refusal
Error: an identical create for this payee is already in flight. ... — on the trigger path this one can follow the dry-run pre-flight call; everything else above is purely local
One pre-flight nuance: the dry-run validator is corporate-agnostic while the
create itself resolves payment rails per corporate, so a rejection whose
only errors are on paymentRail is treated as non-authoritative and the
tool proceeds to the real create (which decides with the corporate's actual
rail set — e.g. a per-corporate rail pilot).
OTP-state errors (each maps one upstream 401/403/429, discriminated by
the API's exact message strings — an unrecognized status/message combination
falls through to generic API-error rendering rather than gaining fabricated
OTP specifics): invalid code (pending draft kept — the user re-reads the
most recent email), expired code (re-trigger), account blocked (hard stop,
names the portal-login lockout), rate-limited (names the 5-per-15-min
window), stale/no active code (re-trigger), and no-Sokin-user-identity (401
— OTP actions can't complete on this connection). A Verification of Payee
mismatch
(422) renders both names verbatim, clears the pending draft (the
code is consumed and that exact draft can only mismatch again), and
instructs the confirm_verified_payee_name restart with a fresh code.
A 201 whose payload lacks the created record is reported as
API error (502) — whether the payee was created is unknown; check
list_beneficiaries before retrying, a blind retry can create a duplicate.

update_beneficiary#

Update Beneficiary · writes (destructive, idempotent) · touches an external system
Sparse PATCH of an existing payee's destination and identity details,
protected by the same two-call OTP flow as create_beneficiary (same
gate, same byte-match — including beneficiary_id — same code mechanics and
error states). Annotated destructive: it overwrites payee details that steer
future payments and the prior state is not recoverable through the API;
idempotent: re-applying the same patch converges.
What cannot be updated: displayName, entityType, countryCode, and
address are immutable upstream ("<field> is immutable. Create a new beneficiary to use a different value.") — and the API only rejects them
after the OTP code has been consumed, so this tool refuses them up
front
: the four parameters are accepted only to reject them clearly (a
schema that silently dropped them would let a mixed patch apply the rest
while the caller believes the rename happened). Changing them means a new
payee via create_beneficiary.
The API's own patch semantics, surfaced in the docstring:
Supply only the fields to change; omitted fields stay unchanged, and a
field cannot be cleared.
A supplied nested object must contain at least one field (rejected
locally otherwise).
For a multi-destination payee, payment_rail and currency_code
together are the destination selector, not an update. Upstream only
catches a missing payment_rail before the code is issued
(paymentRail is required when a beneficiary has multiple destinations.);
a missing currency_code would fail after the code is consumed
(currencyCode is required when a beneficiary has multiple destinations.)
— so the tool requires the two together, locally.
Selectors can ADD: selectors naming a (currency, rail) the payee does
not have, together with destination fields, add that destination — the
route's own description is "Partially update a beneficiary or add a
payment destination
". Confirm which one the user means.
Merge-then-revalidate: the API merges the patch into the stored record
and re-validates the complete result against the create contract, so a
validation error can name fields the patch never sent.
404-before-OTP: the update guard resolves the beneficiary before the
OTP check, so an unknown payee fails with a named not-found and the code
is never consumed — on the trigger call none is emailed at all; on the
redeem call the message says the (already-emailed) code was not consumed.
The lookup is corporate-scoped, so a wrong corporate_reference also
reads as "Beneficiary not found" — the error names both possible causes. (A literal Corporate not found on this route
comes from the command handler, after OTP verification, so that variant
carries no burned-nothing reassurance.)
No dry-run pre-flight exists for patches (unlike create's
batch-validation), so the first call goes straight at the PATCH.
ParameterTypeRequiredDescription
beneficiary_idstringyesThe payee's externalReference; part of the byte-match
currency_code + payment_railstringmulti-destinationDestination selectors, supplied together — not updates
bank_account / e_transfer / virtual_account / sokin_internal / crypto / individual / companyobjectat least oneFields to change (camelCase inner keys)
display_name / entity_type / country_code / address—refuse-onlyImmutable upstream; accepted only to be refused up front, before any code is triggered or consumed
corporate_referencestringnoRequired in practice; part of the byte-match
otp_codestring2nd callThe 6-character code from the user's email
confirm_verified_payee_namebooleannoVoP-mismatch override, same flow as create
content / structuredContent — the otp_required / otp_resent
shapes and the ambiguous-5xx rendering are identical to create_beneficiary's
(the 5xx wording here says whether the update was applied is unknown and
points at get_beneficiary). An identity patch is verifiable from the text:
the patched individual/company keys are echoed back as the record now
reads them; keys the API accepts but never returns (company.taxId, the
individual ID details) are listed as not confirmable from the response
rather than misread as failures; and a patch whose object mismatches the
payee's immutable entityType — which upstream silently drops with a
200 — is called out as IGNORED upstream; a successful update returns the full updated
record through get_beneficiary's shaping
(envelope {"data": {...}},
full destinations, supportedRails/version/security answer dropped),
rendered as:
Payee updated.
Acme Ltd -- company, GB, active
CurrencyRailValidationAccount
GBPfaster_paymentsmatched87654321
Created 2026-01-01T00:00:00Z.
Full response adds: the envelope's message plus the raw record
(supportedRails, version, complete destination payloads — minus the
e-Transfer securityAnswer, withheld even here).
The docstring also carries two required companion steps: run the matching
validate_beneficiary name check on the new bank details before
patching (same rule as create), and run the beneficiary_id validation
after patching an EUR/SEPA destination — the patch resets it to
not_validated, which blocks payments until refreshed.
Validation errors (local, before any network call):
Error: beneficiary_id must not be empty.
Error: display_name cannot be updated -- displayName, entityType, countryCode, and address are immutable on the API. ... — the refuse-only parameters, rejected before anything else runs
Error: bank_account must include at least one field to update -- omit the parameter entirely to leave it unchanged (a field cannot be cleared).
Error: supply individual OR company, not both -- a payee has one immutable entity type, and the mismatched object would be silently ignored upstream after the code is consumed.
Error: supply at least one field to update: a destination object (bank_account / e_transfer / virtual_account / sokin_internal / crypto), individual, or company. ... — also names the SELECTORS rule and the immutable fields
Error: supply payment_rail and currency_code together -- they jointly select which destination to update. ...
The same otp_code shape check, byte-match refusal, superseded-trigger refusal, and in-flight-claim refusal as create_beneficiary (create and update share the upstream action scopes, so a newer external-payee trigger of either tool supersedes a pending external draft of the other).
OTP-state and VoP errors: identical wording to create_beneficiary
(shared implementation), except the VoP mismatch says the payee was NOT
changed
. A 200 whose payload lacks the updated record is reported as
API error (502) — whether the payee was updated is unknown; fetch it with
get_beneficiary before retrying.

list_financial_institutions#

List Financial Institutions · read-only · touches an external system
Reference data listing institutions available for beneficiary creation
(e.g. Canadian EFT). Not corporate-scoped, not paginated, not filterable:
the upstream endpoint accepts no parameters, so neither does this tool.
Parameters: none.
content, a markdown table:
2 financial institutions:
IdNameBank Number
1Royal Bank of Canada003
2Toronto-Dominion Bank004
structuredContent, envelope {"message": "Success.", "data": [...]},
each item:
FieldTypeDescription
idintegerInstitution id, a plain integer
namestringInstitution name
bankNumberstringInstitution's bank number
Full response adds: nothing. Compact is the identity of raw.
This list is a live proxy to a third-party provider that silently drops
any record failing validation, so a real, fetchable institution can be
absent from it. Do not treat it as an exhaustive allow-list.

list_financial_institution_branches#

List Financial Institution Branches · read-only · touches an external
system
Lists an institution's branches (routing numbers and addresses) for
completing a beneficiary. Cursor-paginated.
ParameterTypeRequiredDescription
financial_institution_idinteger or stringyesThe numeric Id from list_financial_institutions (not Bank Number, which is zero-padded and names a different bank). ASCII digits only, at most 9223372036854775807; surrounding whitespace is tolerated
limitintegernoMax branches per page
next_tokenstringnoCursor from the previous page
content, a markdown table:
1 branch:
IdDescriptionRouting NumberAddressCityStatePostal Code
4412Main Branch000312345200 Bay Street, Suite 400TorontoONM5J2J2
structuredContent, envelope {"data": [...], "pagination": {...}},
each item carrying id, description, routingNumber, addressLineOne,
addressLineTwo, city, state, postalCode.
Full response adds: the envelope's message field only.
Validation errors:
ConditionError
Non-numeric id (rejected locally before any upstream call)Error: financial_institution_id must be a numeric id from list_financial_institutions (at most 9223372036854775807), got 'abc'. Call list_financial_institutions to see valid ids.
Unknown numeric id after an empty branches resultUnknown financial institution id: '999'. Call list_financial_institutions to see valid ids.
The unknown-id check runs only on an empty result, never before attempting the
branches call. The branches endpoint has no not-found signal of its own; an
unrecognized id returns an empty list, indistinguishable from an
institution that genuinely has no branches. Because the institutions list
can be missing real records (see above), gating upfront would reject a real
institution's real branches whenever that institution happened to be one of
the dropped ones.

get_documentation_links#

Get Documentation Links · read-only · touches no external system
Returns the URLs of Sokin's own documentation, so an agent can read the
real integration contract instead of guessing an endpoint or an API
version from memory. It returns pointers only, never documentation
content itself.
Answers are specific to the deployment you are connected to, so a client on
UAT can tell it is being handed UAT's documentation.
ParameterTypeRequiredDescription
topic"b2b_api", "mcp_server", or "all"noWhich documentation to look up. Default "all"
b2b_api: the Sokin B2B REST API this server calls: endpoints, auth,
API versions, request/response schemas.
mcp_server: this MCP server itself: its tools, their returns, and
how to connect. Not published on most deployments yet; the response says
so and points back at the tool list the connection already provides.
all: every topic. The default, and the right choice when unsure.
content, a header naming the deployment, a markdown table, then any
applicable notes:
Sokin documentation for the MCP server at https://mcp.uat.sokin.com.
DocumentationURLWhat's there
Sokin B2B API referencehttps://docs.sokin.com/llms.txtThe Sokin B2B REST API this MCP server calls: every endpoint, authentication, API versions, and request/response schemas
Sokin MCP server referencehttps://docs.sokin.com/sokin-mcp-10453423f0This MCP server: the tools it exposes, what each returns, and how to connect a client
Sokin B2B API reference also has a human-readable page at https://docs.sokin.com. Append .md to any page URL listed in the index at https://docs.sokin.com/llms.txt to fetch it as raw markdown; the API pages include the OpenAPI spec.
A topic with nothing configured renders as (none available) rather than
being omitted, and its summary column carries the reason: the row is
always there.
structuredContent, flat, no data envelope:
FieldTypeDescription
mcpServerUrlstringPublic base URL of the deployment that answered, so UAT and production docs are distinguishable without inferring it from the URLs
documentationarrayOne record per requested topic, in a stable order
Every documentation record has the same shape, so it can be iterated
without special-casing per topic:
FieldTypeDescription
topicstringb2b_api or mcp_server
titlestringHuman-readable name of the reference
urlstring, or nullWhere to read it. Null when nothing is configured for that topic
alternateUrlstring, or nullA genuinely different second URL, when one exists; typically the human-readable page when url is an agent-oriented index
summarystringOne line on what lives there, or on why there is no URL
{
  "mcpServerUrl": "https://mcp.uat.sokin.com",
  "documentation": [
    {
      "topic": "b2b_api",
      "title": "Sokin B2B API reference",
      "url": "https://docs.sokin.com/llms.txt",
      "alternateUrl": "https://docs.sokin.com",
      "summary": "The Sokin B2B REST API this MCP server calls: every endpoint, authentication, API versions, and request/response schemas"
    },
    {
      "topic": "mcp_server",
      "title": "Sokin MCP server reference",
      "url": "https://docs.sokin.com/sokin-mcp-10453423f0",
      "alternateUrl": null,
      "summary": "This MCP server: the tools it exposes, what each returns, and how to connect a client"
    }
  ]
}
Where both an agent index and a human page are configured for b2b_api,
the index wins as url and the human page becomes alternateUrl. The
index is a flat list of markdown links; the human page is a client-rendered
application an agent gets little from fetching.
Full response adds: nothing. There is no API call behind this tool, so
compact and raw are the same object by design.

visualize#

Visualize · read-only · touches no external system
Renders Mermaid diagrams and rich markdown inside the MCP client via its
own UI embed (ui://sokin-mcp/visualize). It is a diagramming tool, not a
prettifier: the caller writes the markdown, and this only renders it.
ParameterTypeRequiredDescription
markdownstringyesFull CommonMark + GFM. Fenced blocks tagged mermaid render as diagrams. Raw HTML is sanitized, not rendered
titlestringnoThe card's header. Defaults to a generic heading
Practical constraints worth knowing when generating content for it: card
width is fixed at roughly 600px, height is unconstrained. Prefer
top-to-bottom diagrams (TD, not LR), keep labels short, and keep any
single diagram to roughly 10 to 15 nodes. Do not include a %%{init: ...}%% theme directive; the card applies its own brand theme and yours
would clash.
content, the markdown string, echoed verbatim.
structuredContent, flat, no envelope:
FieldTypeDescription
titlestringThe title as passed
markdownstringThe markdown as passed
Full response adds: nothing; this tool calls no API, so there is no
raw payload to widen.
Validation errors:
markdown must not be empty.

play_cheetah_dash#

Play Cheetah Dash · read-only · touches no external system
Launches an endless-runner arcade game rendered inline via its own UI embed
(ui://sokin-mcp/cheetah-dash). It takes no arguments and calls no Sokin
API.
Parameters: none.
content, a plain string:
Cheetah Dash is ready -- use Space or tap to jump, again mid-air to double jump.
structuredContent: none. This is the only tool on this server that
returns a bare string rather than going through the compact/full
machinery, so it is also the only tool whose success result carries no
structuredContent at all.

Appendix: enumerations#

Every closed value set an integrator will encounter. All of these are read
from the Sokin B2B API's own source definitions, not inferred from observed
responses: a value set you have not seen in testing is still one your code
should handle.

Instruction types#

instructionType on list_instructions and get_instruction, the B2B
API's InstructionType enum (8 values):
ValueMeaning
PaymentMoves funds from a CCA to a beneficiary
FXMoves funds between two CCAs owned by the same corporate, in different currencies
FXPaymentMoves funds from a CCA to a beneficiary in a different currency
UnfundedPaymentFunds may land in the account after the instruction is created, before sending
UnfundedFXPaymentThe unfunded variant of FXPayment
SokinDirectSame-currency transfer between two on-platform CCAs, at zero fees
BoostDepositMoves funds from a CCA, subject to treasury approval before completion
ForwardSettlementSettles a Forward Contract at the contracted forward rate

Instruction display statuses#

displayStatus, the user-facing status, present in the compact response.
The values contain spaces and are shown exactly as they appear (11 values):
Pending finalisation, Approval Initiated, Approval Completed,
Pending Partner, Booking FX Trade, Pending, Pending FX Settlement,
Pending Settlement, Processed, Failed, Unknown.
get_instruction can also return Rejected -- not one of the 11 values
above and not a value the backend itself ever sends. This server
synthesizes it when Failed carries an "Approval rejected: " prefix on
failureReason, to tell a human's portal rejection apart from a real
processing failure (the backend has no such distinction of its own). See
get_instruction's own section for the two fields this adds when it fires.

Instruction internal statuses (full response only)#

status is dropped from the compact response and appears only when the
full-response opt-in is used. The API's own field description calls it the
internal status, though that description is known to omit several real
members; the full enum (14 values) is:
Created, ApprovalInitiated, ApprovalCompleted, Processed, Failed,
PaymentInstructed, FXTradeBooked, OutboundComplianceCompleted,
InboundComplianceCompleted, LiquidityConfirmed, FXTradeSettled,
PaymentSettled, BoostApprovalRequested, BoostApprovalCompleted.
Prefer displayStatus. These values track internal workflow transitions
and are not a stable public contract.

Instruction request statuses#

status on get_instruction_request, the B2B API's
InstructionRequestStatus enum. Exactly 3 values, no more:
Created, Accepted, Rejected.
The naming is counter-intuitive and worth restating: Created means still
processing, Accepted means the instruction now exists, Rejected means
it never will. Render an unrecognized value verbatim rather than mapping it
onto one of these three.
errorCategory alongside it, and on validate_payment_instruction, is
backed upstream by a real closed enum, InstructionRequestErrorCategory
(10 members): fees, amount, amount_missing_funds,
corporate_currency_account, quote, beneficiary_reference,
reseller_reference, reseller_fee, payment_details,
corporate_reference. On the wire, though, both response models declare
this field as a plain optional string, not the enum itself, so treat it as
a documented, currently-closed set rather than a contract you can rely on
never growing a new member.
beneficiary_currency is an eleventh value, but it does not come from
that upstream enum.
validate_payment_instruction and
create_payment_instruction both synthesize it locally, before the
backend is ever called, when a beneficiary genuinely exists but has no
destination in the source account's currency (get_by_reference_and_currency
in sokin-b2b-api-service conflates that case with "does not exist" and
would otherwise return beneficiary_reference for both). Same-currency
only: neither tool converts currencies, and a mismatch here means "retry
with a different beneficiary or account," not "the beneficiary doesn't
exist" -- use create_fx_payment_instruction for a cross-currency payment
to the same beneficiary instead.

Ledger entry type and direction#

On get_account_ledger entries:
direction takes only two values in practice, credit or debit
(populated upstream as item.type.lower() of a Credit/Debit database
enum), though it is typed as a plain string on the wire rather than an
enforced enum.
type is not a short closed set, and this is the field most likely to
break a naive integration. Upstream, only four internal item types collapse
to two friendly labels; everything else is emitted under its own raw
snake_case value. The full set you can receive (11 values):
ValueNotes
depositFunds in
withdrawalFunds out
FX TradeFriendly label covering both fx_trade_in and fx_trade_out legs
Forward settlementFriendly label covering both forward_settlement_in and forward_settlement_out legs
sokin_direct_sendOn-platform CCA-to-CCA transfer, outgoing leg
sokin_direct_receiveOn-platform CCA-to-CCA transfer, incoming leg
boost_depositMove into a Boost position
boost_maturity_payoutBoost payout at maturity
manual_adjustment_fraud_recallOperational adjustment
manual_adjustment_account_offboardingOperational adjustment
manual_adjustment_good_willOperational adjustment
Do not switch-case on deposit/withdrawal/FX Trade alone: the
operational and Sokin Direct values are real values on real accounts, and
new item types can be added upstream over time. Treat an unrecognized
type as displayable rather than an error.
The content table signs amounts from direction (+ for credit, - for
debit), while structuredContent.amount stays unsigned either way.

Account locality statuses#

localAccountStatus and internationalAccountStatus on get_account, the
B2B API's CCALocalityStatus enum. Exactly 4 values, no others:
ValueMeaning
ACTIVEThe account is active and ready to receive payments
PENDINGNot active yet; still processing, or awaiting operational action
NOT_PROVISIONEDCan be set up, but account setup or terms acceptance is incomplete
NOT_AVAILABLEPay-in over this route is unavailable for this account. Also the default value when the field is otherwise unset

Beneficiary entity type and status#

FieldValues
entityTypeindividual, company
statusactive, deleted

Payment rails#

paymentRail on a beneficiary destination, the B2B API's PaymentRail
enum (20 values total):
GroupValues
Internalinternal
Internationalswift
Europesepa
United Kingdomfaster_payments, bacs, chaps
United Statesach, fedwire, fednow
Canadaeft, e_transfer
Asia-Pacificfast_meps, npp
Middle Eastfts_ipi
Latin Americapix
Crypto networksbase, tron, ethereum, polygon, solana
For crypto destinations the rail is the on-chain network itself.

Corporate assignment types#

assignmentType on list_corporates: direct or inherited.

Payment purposes#

payment_purpose on validate_payment_instruction, create_payment_instruction,
submit_payment_instruction, create_fx_payment_instruction, and
submit_fx_payment_instruction is a declared schema enum (ARC-609), not
free text. An unrecognized value is rejected by schema validation before
the tool body runs, the same as an invalid fixed_side. When omitted
entirely, the create endpoints default it to ACCOUNTS_PAYABLE
server-side.
The declared set, 22 values, is what the backend itself calls the "Sokin
catalogue" (PaymentPurpose.active_members(sokin_catalogue=True) in
sokin-b2b-api-service's common/enums.py):
ACCOUNTS_PAYABLE, COMMISSION, CUSTOMER_REFUND, CUSTOMS_DUTIES,
DIVIDEND_DISTRIBUTION, EXPENSE_REIMBURSEMENT, FREELANCER_CONTRACTOR,
INSURANCE_PREMIUM, INTERCOMPANY_TRANSFER, LOAN_REPAYMENT,
MARKETING_ADVERTISING, OFFICE_LEASE, OTHER, PROFESSIONAL_SERVICES,
ROYALTY_LICENSE_FEE, SALARY_PAYROLL, SHIPPING_FREIGHT_LOGISTICS,
SOFTWARE_SUBSCRIPTIONS, SUPPLIER_PAYMENT, TAX_PAYMENT,
TRAVEL_EXPENSE, UTILITIES.
There is no per-corporate or per-reseller catalogue split at the create or
validate level: B2B removed SokinPaymentPurpose entirely (EXAP-290,
EXAP-293), and both endpoints accept the full ~174-value set unconditionally
for every caller. The catalogue split only exists at a separate,
informational endpoint, GET /instruction-requests/payment-purposes, which
reads the caller's X-Api-Version header to decide which list to return
(the older, deprecated revision or the 22 above) -- it has no bearing on
what these tools' own payment_purpose will accept.
The real tradeoff these tools make is narrowing every caller to the 22
above and rejecting the other roughly 152 legacy values (largely Canadian
clearing-system purpose codes such as PAYROLL_DEPOSIT,
RESIDENTIAL_MORTGAGE, and COMMERCIAL_BILL_PAYMENT) client-side. This is
a known, accepted tradeoff, not an oversight: the legacy values are rejected
at the payout provider anyway (MoneyCorp, Ruya), and hardcoding this list on
the MCP server couples it to a catalogue it does not own, so it will drift
if the backend's Sokin catalogue ever changes. Tracked as tech debt, not
treated as a finished fix: platacapital/sokin-mcp#59
(the fix there may be reading the listing endpoint above with
X-Api-Version: 2026-09-01 instead of hardcoding).
One value, OTHER, is known to fail at the payout provider for a
Ruya-routed payment -- Ruya's own purpose-of-payment mapping has no entry
for it. Not specific to these tools; flagged here since it is the one value
in this list a caller could hit it with.

Request parameter enums#

Closed sets on tool inputs. An out-of-range value is rejected by schema
validation before the tool body runs:
ParameterToolValues
fixed_sideget_fx_quote, create_fx_quote, create_fx_payment_instructionBUY, SELL (default SELL)
search_modelist_beneficiariescontains, starts_with (default contains)
topicget_documentation_linksb2b_api, mcp_server, all (default all)
payment_purposevalidate_payment_instruction, create_payment_instruction, submit_payment_instruction, create_fx_payment_instruction, submit_fx_payment_instruction22 values, see "Payment purposes" above
Modified at 2026-10-07 11:36:17
Previous
Sokin MCP - Tool Guide
Next
Get Token
Built with