Kommo + Wise Business: Automatic Contractor Payments When Closing a Deal
The Wise Business API lets you create international transfers programmatically: select a recipient, specify the amount and currency, confirm - and the money is sent without manually logging into your bank. For agencies and consulting firms that pay contractors from the Kommo pipeline, this integration closes a classic gap: a manager moves a deal to the “Pay” stage, and the payment is created automatically.
The Wise API uses a Bearer token (API Key from Wise Business -> Developer Tools). The core flow involves several calls: GET /v3/profiles -> POST /v3/quotes -> POST /v3/accounts -> POST /v3/transfers -> POST /v3/transfers/{id}/payments. Each step depends on the result of the previous one - which is exactly why Zapier cannot handle this task.
Wise Business is an API-accessible international money transfer service supporting 80+ currencies and multi-currency balances. It differs from Wise Personal: a Business account requires company verification, and unlocks the full API and batch payments.
Why Zapier Cannot Handle This
The Wise connector in Zapier supports only simple single-step operations. The transfer flow requires at least 4-5 API calls in strict sequence: first retrieve the profileId, then create a quote (tied to a specific amount and exchange rate), then verify the recipient exists, then create the transfer, and separately confirm it. No low-code tool can model this dependency graph without custom code.
Typical scale: an agency with 20-30 contractor payments per month spends 2-3 hours on manual transfers. The integration reduces that to a single manager action in Kommo.
Architecture
Kommo: deal -> "Pay Contractor" stage
-> Kommo webhook leads.status.changed
-> Your server
Your server:
1. GET /v3/profiles -> profileId
2. POST /v3/profiles/{profileId}/quotes -> quoteId
3. GET or POST /v3/accounts -> recipientAccountId (cached in Kommo)
4. POST /v3/transfers -> transferId
5. POST /v3/profiles/{profileId}/transfers/{transferId}/payments -> send
-> Kommo: note with transferId and status
The recipientAccountId is cached in a custom field on the Kommo contact. For repeat payments to the same contractor, step 3 is skipped.
Implementation
import requests, os, uuid
from flask import Flask, request, jsonify
app = Flask(__name__)
WISE_API_KEY = os.environ["WISE_API_KEY"]
WISE_BASE = "https://api.transferwise.com"
WISE_HDR = {"Authorization": f"Bearer {WISE_API_KEY}",
"Content-Type": "application/json"}
KOMMO_SUBDOMAIN = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN = os.environ["KOMMO_ACCESS_TOKEN"]
PAYOUT_STAGE_ID = int(os.environ["KOMMO_PAYOUT_STAGE_ID"])
KOMMO_BASE = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HDR = {"Authorization": f"Bearer {KOMMO_TOKEN}",
"Content-Type": "application/json"}
CF_WISE_ACCOUNT_ID = int(os.environ["CF_WISE_ACCOUNT_ID"])
CF_PAYOUT_AMOUNT = int(os.environ["CF_PAYOUT_AMOUNT"])
CF_PAYOUT_CURRENCY = int(os.environ["CF_PAYOUT_CURRENCY"])
def get_profile_id() -> int:
r = requests.get(f"{WISE_BASE}/v3/profiles", headers=WISE_HDR)
r.raise_for_status()
for p in r.json():
if p["type"] == "BUSINESS":
return p["id"]
raise ValueError("No BUSINESS profile")
def create_quote(profile_id: int, source_cur: str,
target_cur: str, amount: float) -> str:
r = requests.post(
f"{WISE_BASE}/v3/profiles/{profile_id}/quotes",
headers=WISE_HDR,
json={
"sourceCurrency": source_cur,
"targetCurrency": target_cur,
"targetAmount": amount,
"payOut": "BANK_TRANSFER",
},
)
r.raise_for_status()
return r.json()["id"]
def get_or_create_recipient(profile_id: int, contact: dict) -> int:
existing = get_cf(contact, CF_WISE_ACCOUNT_ID)
if existing:
return int(existing)
name = contact.get("name", "")
email = get_email(contact)
r = requests.post(
f"{WISE_BASE}/v3/accounts",
headers=WISE_HDR,
json={
"currency": "USD",
"type": "email",
"profile": profile_id,
"accountHolderName": name,
"details": {"email": email},
},
)
r.raise_for_status()
account_id = r.json()["id"]
save_cf(contact["id"], CF_WISE_ACCOUNT_ID, str(account_id))
return account_id
def create_transfer(quote_id: str, recipient_id: int, lead_id: int) -> int:
r = requests.post(
f"{WISE_BASE}/v3/transfers",
headers=WISE_HDR,
json={
"targetAccount": recipient_id,
"quoteUuid": quote_id,
"customerTransactionId": str(uuid.uuid4()),
"details": {"reference": f"Kommo deal #{lead_id}"},
},
)
r.raise_for_status()
return r.json()["id"]
def fund_transfer(profile_id: int, transfer_id: int) -> str:
r = requests.post(
f"{WISE_BASE}/v3/profiles/{profile_id}/transfers/{transfer_id}/payments",
headers=WISE_HDR,
json={"type": "BALANCE"},
)
r.raise_for_status()
return r.json().get("status", "unknown")
def get_cf(entity: dict, field_id: int) -> str:
for cf in entity.get("custom_fields_values", []) or []:
if cf.get("field_id") == field_id:
vals = cf.get("values", [])
return vals[0].get("value", "") if vals else ""
return ""
def get_email(contact: dict) -> str:
for cf in contact.get("custom_fields_values", []) or []:
if cf.get("field_code") == "EMAIL":
vals = cf.get("values", [])
return vals[0].get("value", "") if vals else ""
return ""
def save_cf(contact_id: int, field_id: int, value: str):
requests.patch(
f"{KOMMO_BASE}/contacts/{contact_id}",
headers=KOMMO_HDR,
json={"custom_fields_values": [
{"field_id": field_id, "values": [{"value": value}]}
]},
)
def add_note(lead_id: int, text: str):
requests.post(
f"{KOMMO_BASE}/notes",
headers=KOMMO_HDR,
json=[{"entity_id": lead_id, "entity_type": "leads",
"note_type": "common", "params": {"text": text}}],
)
def get_lead_contact(lead_id: int) -> tuple:
r = requests.get(
f"{KOMMO_BASE}/leads/{lead_id}",
headers=KOMMO_HDR,
params={"with": "contacts,custom_fields_values"},
)
lead = r.json()
contacts = lead.get("_embedded", {}).get("contacts", [])
contact = {}
if contacts:
rc = requests.get(
f"{KOMMO_BASE}/contacts/{contacts[0]['id']}",
headers=KOMMO_HDR,
params={"with": "custom_fields_values"},
)
contact = rc.json()
return lead, contact
@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
data = request.json or {}
for lead_data in data.get("leads", {}).get("status", []):
lead_id = lead_data.get("id")
new_status = lead_data.get("status_id")
if new_status != PAYOUT_STAGE_ID:
continue
lead, contact = get_lead_contact(lead_id)
amount = float(get_cf(lead, CF_PAYOUT_AMOUNT) or lead.get("price") or 0)
currency = get_cf(lead, CF_PAYOUT_CURRENCY) or "USD"
if amount <= 0:
add_note(lead_id, "Wise: payout amount not set.")
continue
profile_id = get_profile_id()
quote_id = create_quote(profile_id, "USD", currency, amount)
recipient = get_or_create_recipient(profile_id, contact)
transfer_id = create_transfer(quote_id, recipient, lead_id)
status = fund_transfer(profile_id, transfer_id)
add_note(lead_id,
f"Wise transfer #{transfer_id}: {amount} {currency}, status: {status}")
return jsonify({"status": "ok"}), 200
Wise Webhook for Status Tracking
Wise sends transfer status events via subscriptions. To create a subscription:
def subscribe_wise_webhooks(profile_id: int,
callback_url: str, secret: str) -> str:
r = requests.post(
f"{WISE_BASE}/v3/subscriptions",
headers=WISE_HDR,
json={
"name": "Kommo transfer updates",
"trigger_on": "transfers#state-change",
"delivery": {
"version": "2.0",
"url": callback_url,
"signature": {
"type": "SHA256_HMAC",
"token": secret,
},
},
"scope": {
"domain": "profile",
"id": str(profile_id),
},
},
)
r.raise_for_status()
return r.json()["id"]
@app.route("/webhooks/wise", methods=["POST"])
def wise_webhook():
import hmac, hashlib
secret = os.environ["WISE_WEBHOOK_SECRET"]
sig = request.headers.get("X-Signature-SHA256", "")
body = request.get_data()
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return jsonify({"error": "invalid signature"}), 401
event = request.json or {}
if event.get("event_type") != "transfers#state-change":
return jsonify({"status": "ignored"}), 200
resource = event.get("data", {}).get("resource", {})
t_id = resource.get("id")
new_state = resource.get("current_state")
# outgoing_payment_sent = funds sent
# funds_refunded = refund
print(f"Transfer {t_id} -> {new_state}")
return jsonify({"status": "ok"}), 200
Key states: processing, funds_converted, outgoing_payment_sent, funds_refunded.
Sandbox for Testing
Wise provides a sandbox at sandbox.transferwise.com. Create a separate key in Wise Business -> Developer Tools -> Sandbox. All sandbox transfers require no real funds, so you can test the entire flow including webhook events.
Batch Payments
For 10+ payments per day, use POST /v3/profiles/{profileId}/batch-payments - a single request with an array of transferIds instead of N separate payment calls. Transfers are created one by one (each with a unique customerTransactionId), but confirmed with a single batch request.
Who This Is For
Agencies and consulting firms with international contractors: designers in the EU, developers in the UK/Canada/Australia, freelancers worldwide. The typical scenario: a project is won in the Kommo pipeline, the deal is closed, and the fee needs to be paid - without switching to a bank and without manual work. Especially relevant for teams with 15+ payments per month, where manual processing becomes a recurring time drain.
Alternative payment integrations: Kommo + Razorpay (India), Kommo + Flutterwave (Africa).
Frequently Asked Questions
Can transfers be created without manual confirmation?
Yes. The POST .../transfers/{id}/payments step with {"type": "BALANCE"} confirms the transfer automatically. Prerequisite: sufficient balance in the source currency. Add a balance check via GET /v4/profiles/{profileId}/balances before creating the transfer.
How do I store a contractor’s wise_account_id in Kommo?
Create a custom “Text” field in the Kommo Contacts section. When creating the first transfer, write the recipientAccountId to that field via PATCH /api/v4/contacts/{id}. For subsequent payments to the same contractor, read the field and skip the POST /v3/accounts step.
What if the Wise balance is insufficient?
Wise will return HTTP 422 with the code INSUFFICIENT_BALANCE at the payments step. Handle this: catch the error, add a note to Kommo saying “Wise: top up balance for transfer {amount} {currency}”, and exit without crashing. Balance monitoring is a separate task - either via the Wise API or Wise Dashboard notifications.
Does Wise support transfers to crypto wallets?
No. Wise only supports bank transfers: IBAN, SWIFT, and local schemes (ACH, SEPA, Faster Payments). For crypto payouts, a separate service is needed (Coinbase Commerce, BitPay).
Summary
Kommo + Wise Business - automated contractor payments:
- Bearer token, required flow: profile -> quote -> account -> transfer -> payment
- Cache
recipientAccountIdin a custom Kommo contact field - Webhook
transfers#state-changevia SHA256_HMAC subscription - Sandbox:
sandbox.transferwise.comfor full testing with no real money - 80+ currencies, mid-market rate, transparent fees
If your team pays international contractors through Kommo - describe your setup to the Exceltic.dev team. We will work through the architecture for your stack.