Kommo + Wise Business: Automatic Contractor Payments When Closing a Deal

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 recipientAccountId in a custom Kommo contact field
  • Webhook transfers#state-change via SHA256_HMAC subscription
  • Sandbox: sandbox.transferwise.com for 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.

More articles

All →