langchain-send21 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-send21
3
+ Version: 0.1.0
4
+ Summary: send21 tools for LangChain: prepare non-custodial pay links. The payer signs in their own wallet.
5
+ Project-URL: Homepage, https://send21.io
6
+ Project-URL: Documentation, https://send21.io/docs
7
+ Project-URL: Source, https://github.com/send21io/send21-examples/tree/main/integrations/langchain
8
+ Author-email: send21 <info@send21.io>
9
+ License: MIT
10
+ Keywords: bitcoin,langchain,lightning,non-custodial,payments,send21,tools,usdc
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: langchain-core<2,>=0.3
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # langchain-send21
19
+
20
+ send21 tools for LangChain. Let an agent prepare a pay link and check its status without holding keys.
21
+
22
+ send21 is non-custodial software that prepares payment instructions. It never holds keys or funds, never signs and never broadcasts. The scoped API key this package uses can create a pay link but cannot move money: the payer signs in their own wallet.
23
+
24
+ Not an official LangChain package. Questions: info@send21.io
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ pip install langchain-send21
30
+ export SEND21_API_KEY=s21_... # scopes: drafts:write, drafts:read (https://send21.io/api-keys)
31
+ ```
32
+
33
+ ## Use
34
+
35
+ ```python
36
+ from langchain.agents import create_agent
37
+ from langchain_send21 import PayOption, Send21Toolkit
38
+
39
+ tools = Send21Toolkit(receive_options=[
40
+ PayOption(currency="Usdc", network="Solana", address="<your-solana-address>"),
41
+ PayOption(currency="Btc", network="Mainnet", address="name@wallet.com", method="Lightning"),
42
+ ]).get_tools()
43
+
44
+ agent = create_agent("openai:gpt-5-mini", tools=tools)
45
+ agent.invoke({"messages": [{"role": "user", "content": "Create a pay link for 40 EUR for order inv-1042."}]})
46
+ ```
47
+
48
+ - `send21_create_pay_link(fiat_amount, fiat_currency, order_id?, memo?, decline_note?)` returns `pay_link`, `payment_request_id`, `status`, `expires_at`. Receiving addresses come from your code, never from the model. Each call uses a fresh idempotency key, and with an `order_id` a retry returns the open link instead of creating a second one.
49
+ - `send21_get_pay_link_status(payment_request_id)` returns `Open`, `Completed`, `Cancelled` or `Expired` plus any "what changed" notes.
50
+
51
+ Supported pairs: BTC on Bitcoin and Lightning; USDC on Solana, Ethereum, Base, Arbitrum, Polygon; USDT on Solana and Ethereum; EURC on Solana, Ethereum, Base; SOL; ETH on Ethereum, Base, Arbitrum. Others are refused before any API call.
52
+
53
+ Already using MCP? `langchain-mcp-adapters` can load all 11 tools from https://send21.io/mcp directly.
54
+
55
+ ## Develop
56
+
57
+ ```sh
58
+ pip install -e '.[test]' && pytest # offline, mocked HTTP
59
+ ```
60
+
61
+ MIT
@@ -0,0 +1,15 @@
1
+ # Draft listing: LangChain docs (issue form, not a PR)
2
+
3
+ STATUS: draft only. LangChain no longer accepts integration PRs. Listing = "Integration listing" issue in langchain-ai/docs; a maintainer labels it `integration-run` and automation adds a YAML row. Prereqs: publish `langchain-send21` to PyPI (name free as of 2026-10-14), public source repo, docs URL. A human must file the issue by hand from the send21io account (GitHub Support commitment).
4
+
5
+ Source: https://docs.langchain.com/oss/python/contributing/integrations-langchain
6
+
7
+ ## Issue fields
8
+ - Package name: langchain-send21
9
+ - Component type: Tools / Toolkits
10
+ - PyPI: https://pypi.org/project/langchain-send21/
11
+ - Docs URL: https://send21.io/docs (or a dedicated https://send21.io/docs/langchain once written)
12
+ - Repo: https://github.com/send21io/send21-examples (or a dedicated repo)
13
+
14
+ ## Description
15
+ send21 tools let a LangChain agent create a pay link priced in fiat and read its status. send21 is non-custodial: it prepares payment instructions and never holds keys or funds or signs, so the scoped API key cannot move money. The payer picks BTC (on-chain or Lightning), USDC, USDT, EURC, SOL or ETH and signs in their own wallet. Receiving addresses are fixed by the developer, not chosen by the model.
@@ -0,0 +1,44 @@
1
+ # langchain-send21
2
+
3
+ send21 tools for LangChain. Let an agent prepare a pay link and check its status without holding keys.
4
+
5
+ send21 is non-custodial software that prepares payment instructions. It never holds keys or funds, never signs and never broadcasts. The scoped API key this package uses can create a pay link but cannot move money: the payer signs in their own wallet.
6
+
7
+ Not an official LangChain package. Questions: info@send21.io
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install langchain-send21
13
+ export SEND21_API_KEY=s21_... # scopes: drafts:write, drafts:read (https://send21.io/api-keys)
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```python
19
+ from langchain.agents import create_agent
20
+ from langchain_send21 import PayOption, Send21Toolkit
21
+
22
+ tools = Send21Toolkit(receive_options=[
23
+ PayOption(currency="Usdc", network="Solana", address="<your-solana-address>"),
24
+ PayOption(currency="Btc", network="Mainnet", address="name@wallet.com", method="Lightning"),
25
+ ]).get_tools()
26
+
27
+ agent = create_agent("openai:gpt-5-mini", tools=tools)
28
+ agent.invoke({"messages": [{"role": "user", "content": "Create a pay link for 40 EUR for order inv-1042."}]})
29
+ ```
30
+
31
+ - `send21_create_pay_link(fiat_amount, fiat_currency, order_id?, memo?, decline_note?)` returns `pay_link`, `payment_request_id`, `status`, `expires_at`. Receiving addresses come from your code, never from the model. Each call uses a fresh idempotency key, and with an `order_id` a retry returns the open link instead of creating a second one.
32
+ - `send21_get_pay_link_status(payment_request_id)` returns `Open`, `Completed`, `Cancelled` or `Expired` plus any "what changed" notes.
33
+
34
+ Supported pairs: BTC on Bitcoin and Lightning; USDC on Solana, Ethereum, Base, Arbitrum, Polygon; USDT on Solana and Ethereum; EURC on Solana, Ethereum, Base; SOL; ETH on Ethereum, Base, Arbitrum. Others are refused before any API call.
35
+
36
+ Already using MCP? `langchain-mcp-adapters` can load all 11 tools from https://send21.io/mcp directly.
37
+
38
+ ## Develop
39
+
40
+ ```sh
41
+ pip install -e '.[test]' && pytest # offline, mocked HTTP
42
+ ```
43
+
44
+ MIT
@@ -0,0 +1,13 @@
1
+ import os
2
+
3
+ from langchain.agents import create_agent # langchain>=1.0
4
+ from langchain_send21 import PayOption, Send21Toolkit
5
+
6
+ tools = Send21Toolkit(receive_options=[
7
+ PayOption(currency="Usdc", network="Solana", address=os.environ["MY_SOLANA_ADDRESS"]),
8
+ PayOption(currency="Btc", network="Mainnet", address=os.environ["MY_LIGHTNING_ADDRESS"], method="Lightning"),
9
+ ]).get_tools()
10
+
11
+ agent = create_agent("openai:gpt-5-mini", tools=tools)
12
+ result = agent.invoke({"messages": [{"role": "user", "content": "Create a pay link for 40 EUR for order inv-1042."}]})
13
+ print(result["messages"][-1].content)
@@ -0,0 +1,14 @@
1
+ """send21 tools for LangChain.
2
+
3
+ send21 prepares payment instructions. It never holds keys or funds, never signs and
4
+ never broadcasts. A key with drafts:write can create a pay link but cannot move money.
5
+ """
6
+ from langchain_send21.tools import (
7
+ PayOption,
8
+ Send21Client,
9
+ Send21CreatePayLinkTool,
10
+ Send21GetPayLinkStatusTool,
11
+ Send21Toolkit,
12
+ )
13
+
14
+ __all__ = ["PayOption", "Send21Client", "Send21CreatePayLinkTool", "Send21GetPayLinkStatusTool", "Send21Toolkit"]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from typing import Any, Literal, Optional
6
+ from urllib.parse import urljoin
7
+
8
+ import httpx
9
+ from langchain_core.tools import BaseTool, BaseToolkit
10
+ from pydantic import BaseModel, Field, PrivateAttr
11
+
12
+ # Supported asset/network pairs. USDT only on Solana and Ethereum, no ETH on Polygon.
13
+ SUPPORTED = {
14
+ "Btc": {"Mainnet"},
15
+ "Usdc": {"Solana", "Ethereum", "Base", "Arbitrum", "Polygon"},
16
+ "Usdt": {"Solana", "Ethereum"},
17
+ "Eurc": {"Solana", "Ethereum", "Base"},
18
+ "Sol": {"Solana"},
19
+ "Eth": {"Ethereum", "Base", "Arbitrum"},
20
+ }
21
+
22
+
23
+ class PayOption(BaseModel):
24
+ currency: Literal["Btc", "Usdc", "Usdt", "Eurc", "Sol", "Eth"]
25
+ network: Literal["Mainnet", "Solana", "Ethereum", "Base", "Arbitrum", "Polygon"]
26
+ address: str = Field(description="The receiver's own address, or Lightning address name@wallet.com")
27
+ method: Optional[Literal["MultiOutput", "Lightning"]] = None
28
+
29
+ def check(self) -> None:
30
+ if self.network not in SUPPORTED[self.currency]:
31
+ raise ValueError(f"{self.currency} is not supported on {self.network}")
32
+ if self.method == "Lightning" and self.currency != "Btc":
33
+ raise ValueError("Lightning is only for Btc")
34
+
35
+
36
+ class Send21Client:
37
+ """Thin REST client. Uses a scoped send21 API key (https://send21.io/api-keys)."""
38
+
39
+ def __init__(self, api_key: str | None = None, base_url: str = "https://send21.io",
40
+ http: httpx.Client | None = None) -> None:
41
+ self.api_key = api_key or os.environ.get("SEND21_API_KEY")
42
+ if not self.api_key:
43
+ raise ValueError("Set SEND21_API_KEY (create a scoped key at https://send21.io/api-keys)")
44
+ self.base_url = base_url.rstrip("/")
45
+ self.http = http or httpx.Client(timeout=30)
46
+
47
+ def _req(self, method: str, path: str, **kw: Any) -> dict:
48
+ headers = {"Authorization": f"Bearer {self.api_key}", **kw.pop("headers", {})}
49
+ r = self.http.request(method, self.base_url + path, headers=headers, **kw)
50
+ if r.status_code >= 400:
51
+ raise RuntimeError(f"send21 {r.status_code}: {r.text[:500]}")
52
+ return r.json()
53
+
54
+ def create_payment_request(self, body: dict) -> dict:
55
+ return self._req("POST", "/api/v1/payment-requests", json=body,
56
+ headers={"Idempotency-Key": str(uuid.uuid4())})
57
+
58
+ def get_payment_request(self, pr_id: str) -> dict:
59
+ return self._req("GET", f"/api/v1/payment-requests/{pr_id}")
60
+
61
+
62
+ class CreatePayLinkInput(BaseModel):
63
+ fiat_amount: float = Field(gt=0, description="Amount in the fiat currency")
64
+ fiat_currency: str = Field(min_length=3, max_length=3, description="ISO 4217 code, e.g. EUR")
65
+ order_id: Optional[str] = Field(None, description="Your reference, echoed in webhooks")
66
+ memo: Optional[str] = Field(None, max_length=500)
67
+ decline_note: Optional[str] = Field(None, max_length=200, description="Shown as 'If you don't pay:'")
68
+
69
+
70
+ class Send21CreatePayLinkTool(BaseTool):
71
+ name: str = "send21_create_pay_link"
72
+ description: str = (
73
+ "Create a send21 pay link so a human can pay you. Returns a link; the payer picks a currency, "
74
+ "scans one QR and signs in their own wallet. This tool cannot move funds."
75
+ )
76
+ args_schema: type[BaseModel] = CreatePayLinkInput
77
+ receive_options: list[PayOption]
78
+ _client: Send21Client = PrivateAttr()
79
+
80
+ def __init__(self, client: Send21Client, receive_options: list[PayOption], **kw: Any) -> None:
81
+ super().__init__(receive_options=receive_options, **kw)
82
+ for o in receive_options:
83
+ o.check()
84
+ self._client = client
85
+
86
+ def _run(self, fiat_amount: float, fiat_currency: str, order_id: str | None = None,
87
+ memo: str | None = None, decline_note: str | None = None, **_: Any) -> dict:
88
+ pr = self._client.create_payment_request({
89
+ "fiatAmount": fiat_amount,
90
+ "fiatCurrency": fiat_currency.upper(),
91
+ "options": [o.model_dump(exclude_none=True) for o in self.receive_options],
92
+ "orderId": order_id,
93
+ "memo": memo,
94
+ "declineNote": decline_note,
95
+ "reuseOpenForOrder": True if order_id else None,
96
+ })
97
+ return {
98
+ "payment_request_id": pr["id"],
99
+ "pay_link": urljoin(self._client.base_url + "/", pr["payPath"].lstrip("/")),
100
+ "status": pr.get("status"),
101
+ "expires_at": pr.get("expiresAt"),
102
+ }
103
+
104
+
105
+ class StatusInput(BaseModel):
106
+ payment_request_id: str
107
+
108
+
109
+ class Send21GetPayLinkStatusTool(BaseTool):
110
+ name: str = "send21_get_pay_link_status"
111
+ description: str = "Get the status of a send21 pay link (Open, Completed, Cancelled, Expired)."
112
+ args_schema: type[BaseModel] = StatusInput
113
+ _client: Send21Client = PrivateAttr()
114
+
115
+ def __init__(self, client: Send21Client, **kw: Any) -> None:
116
+ super().__init__(**kw)
117
+ self._client = client
118
+
119
+ def _run(self, payment_request_id: str, **_: Any) -> dict:
120
+ pr = self._client.get_payment_request(payment_request_id)
121
+ return {"status": pr.get("status"), "draft_status": pr.get("draftStatus"),
122
+ "expires_at": pr.get("expiresAt"), "change_notes": pr.get("changeNotes") or []}
123
+
124
+
125
+ class Send21Toolkit(BaseToolkit):
126
+ """Both tools. Receiving addresses are fixed by the app, never chosen by the model."""
127
+
128
+ receive_options: list[PayOption]
129
+ api_key: Optional[str] = None
130
+ base_url: str = "https://send21.io"
131
+
132
+ def get_tools(self) -> list[BaseTool]:
133
+ c = Send21Client(self.api_key, self.base_url)
134
+ return [Send21CreatePayLinkTool(c, self.receive_options), Send21GetPayLinkStatusTool(c)]
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "langchain-send21"
3
+ version = "0.1.0"
4
+ description = "send21 tools for LangChain: prepare non-custodial pay links. The payer signs in their own wallet."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "send21", email = "info@send21.io" }]
8
+ requires-python = ">=3.10"
9
+ dependencies = ["langchain-core>=0.3,<2", "httpx>=0.27"]
10
+ keywords = ["langchain", "tools", "payments", "non-custodial", "send21", "usdc", "bitcoin", "lightning"]
11
+
12
+ [project.urls]
13
+ Homepage = "https://send21.io"
14
+ Documentation = "https://send21.io/docs"
15
+ Source = "https://github.com/send21io/send21-examples/tree/main/integrations/langchain"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
@@ -0,0 +1,28 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from langchain_send21 import PayOption, Send21Client, Send21CreatePayLinkTool
7
+
8
+
9
+ def make_client(seen):
10
+ def handler(req: httpx.Request):
11
+ seen.append(req)
12
+ return httpx.Response(201, json={"id": "abc", "payPath": "/pay/xyz", "status": "Open", "expiresAt": "2026-10-15T20:00:00Z"})
13
+ return Send21Client("s21_test", http=httpx.Client(transport=httpx.MockTransport(handler)))
14
+
15
+
16
+ def test_create_pay_link():
17
+ seen = []
18
+ tool = Send21CreatePayLinkTool(make_client(seen), [PayOption(currency="Usdc", network="Solana", address="OWN")])
19
+ out = tool.invoke({"fiat_amount": 25, "fiat_currency": "eur", "order_id": "inv-1"})
20
+ assert out["pay_link"] == "https://send21.io/pay/xyz"
21
+ body = json.loads(seen[0].content)
22
+ assert body["fiatCurrency"] == "EUR" and body["options"][0]["address"] == "OWN" and body["reuseOpenForOrder"]
23
+ assert seen[0].headers["Idempotency-Key"] and seen[0].headers["Authorization"] == "Bearer s21_test"
24
+
25
+
26
+ def test_rejects_usdt_on_polygon():
27
+ with pytest.raises(ValueError):
28
+ Send21CreatePayLinkTool(make_client([]), [PayOption(currency="Usdt", network="Polygon", address="x")])