send21-haystack 0.1.0__py3-none-any.whl

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,8 @@
1
+ """send21 for Haystack. Not an official deepset integration."""
2
+ from .client import MockSend21Client, Send21MCPClient, build_create_args, pay_link
3
+ from .component import Send21PaymentRequest
4
+ from .webhook import verify_send21_signature
5
+
6
+ __all__ = ["Send21PaymentRequest", "verify_send21_signature", "Send21MCPClient",
7
+ "MockSend21Client", "build_create_args", "pay_link"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,126 @@
1
+ """send21 MCP client (live) and a deterministic mock with the same interface.
2
+
3
+ Verified against github.com/send21io/send21-examples (mcp-payment-request):
4
+ - endpoint https://send21.io/mcp, Streamable HTTP, Authorization: Bearer <key>
5
+ - tool create_payment_request with fiatCurrency, fiatAmount, options[{currency,network,address,method?}],
6
+ orderId, memo, expiryHours, idempotencyKey (all keys sent, null for optional ones)
7
+ - result content[0].text is JSON with id and payPath; pay link = https://send21.io + payPath
8
+
9
+ Only create_payment_request is used. Arguments are also validated against the live
10
+ inputSchema at call time.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ from decimal import Decimal
17
+ from typing import Any
18
+ from urllib.parse import urljoin
19
+
20
+ SITE_URL = "https://send21.io"
21
+ TOOLS = ("create_payment_request",)
22
+
23
+
24
+ def pay_link(pay_path: str) -> str:
25
+ return urljoin(SITE_URL, pay_path)
26
+
27
+
28
+ def build_create_args(*, amount: Decimal, fiat_currency: str, currency: str, network: str, address: str,
29
+ order_id: str, memo: str, idempotency_key: str, expiry_hours: int | None = None) -> dict:
30
+ return {
31
+ "fiatCurrency": fiat_currency.upper(),
32
+ "fiatAmount": float(amount),
33
+ "options": [{"currency": currency, "network": network, "address": address}],
34
+ "orderId": order_id,
35
+ "memo": memo,
36
+ "expiryHours": expiry_hours,
37
+ "idempotencyKey": idempotency_key,
38
+ }
39
+
40
+
41
+ def _type_of(v: Any) -> str:
42
+ if v is None:
43
+ return "null"
44
+ if isinstance(v, bool):
45
+ return "boolean"
46
+ if isinstance(v, int):
47
+ return "integer"
48
+ if isinstance(v, float):
49
+ return "number"
50
+ if isinstance(v, str):
51
+ return "string"
52
+ if isinstance(v, list):
53
+ return "array"
54
+ return "object"
55
+
56
+
57
+ def validate(value: Any, schema: dict, path: str = "arguments") -> list[str]:
58
+ """Minimal JSON Schema check (type, required, properties, items), ported from the TS example."""
59
+ errors: list[str] = []
60
+ t = schema.get("type")
61
+ if t:
62
+ allowed = t if isinstance(t, list) else [t]
63
+ actual = _type_of(value)
64
+ if not (actual in allowed or (actual == "integer" and "number" in allowed)):
65
+ return [f"{path}: expected {' or '.join(allowed)}, got {actual}"]
66
+ if isinstance(value, dict):
67
+ for key in schema.get("required", []):
68
+ if key not in value:
69
+ errors.append(f"{path}.{key}: required")
70
+ for key, sub in (schema.get("properties") or {}).items():
71
+ if key in value:
72
+ errors += validate(value[key], sub, f"{path}.{key}")
73
+ if isinstance(value, list) and schema.get("items"):
74
+ for i, item in enumerate(value):
75
+ errors += validate(item, schema["items"], f"{path}[{i}]")
76
+ return errors
77
+
78
+
79
+ class MockSend21Client:
80
+ """No network. Deterministic ids so demo output is identical every run."""
81
+
82
+ def __init__(self) -> None:
83
+ self.calls: list[tuple[str, dict]] = []
84
+ self._by_key: dict[str, dict] = {}
85
+
86
+ def call(self, tool: str, args: dict) -> dict:
87
+ if tool not in TOOLS:
88
+ raise ValueError(f"unknown tool {tool}")
89
+ self.calls.append((tool, args))
90
+ if tool == "create_payment_request":
91
+ key = args["idempotencyKey"]
92
+ if key not in self._by_key: # same key returns the same request, like the real API
93
+ n = len(self._by_key) + 1
94
+ self._by_key[key] = {"id": f"demo-{n:04d}", "payPath": f"/p/demo-{n:04d}", "status": "mock"}
95
+ return dict(self._by_key[key])
96
+ raise ValueError(tool)
97
+
98
+
99
+ class Send21MCPClient:
100
+ """Live client over the send21 MCP server. Sync wrapper around the async MCP SDK."""
101
+
102
+ def __init__(self, url: str, api_key: str) -> None:
103
+ self.url, self.api_key = url, api_key
104
+
105
+ async def _call(self, tool: str, args: dict) -> dict:
106
+ from mcp import ClientSession
107
+ from mcp.client.streamable_http import streamablehttp_client
108
+
109
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
110
+ async with streamablehttp_client(self.url, headers=headers) as (read, write, _):
111
+ async with ClientSession(read, write) as session:
112
+ await session.initialize()
113
+ tools = {t.name: t for t in (await session.list_tools()).tools}
114
+ if tool not in tools:
115
+ raise RuntimeError(f"The server does not offer {tool}")
116
+ problems = validate(args, tools[tool].inputSchema or {})
117
+ if problems:
118
+ raise RuntimeError(f"Arguments do not match the live {tool} schema: {problems}")
119
+ result = await session.call_tool(tool, args)
120
+ text = "\n".join(c.text for c in result.content if getattr(c, "type", "") == "text")
121
+ if result.isError:
122
+ raise RuntimeError(text or f"{tool} failed")
123
+ return json.loads(text)
124
+
125
+ def call(self, tool: str, args: dict) -> dict:
126
+ return asyncio.run(self._call(tool, args))
@@ -0,0 +1,67 @@
1
+ """Haystack component: ask send21 to prepare a payment request and return the pay link.
2
+
3
+ send21 prepares payment instructions. It never holds keys or funds, never signs and never
4
+ broadcasts. Use an API key with the drafts:write scope only; such a key cannot move funds.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import uuid
10
+ from decimal import Decimal
11
+ from typing import Any, Optional
12
+
13
+ from haystack import component, default_from_dict, default_to_dict
14
+ from haystack.utils import Secret, deserialize_secrets_inplace
15
+
16
+ from .client import Send21MCPClient, build_create_args, pay_link
17
+
18
+ MCP_URL = "https://send21.io/mcp"
19
+
20
+
21
+ @component
22
+ class Send21PaymentRequest:
23
+ """Creates a send21 payment request and returns its id and pay link.
24
+
25
+ Usage:
26
+ pr = Send21PaymentRequest(currency="USDC", network="base", address="0x...")
27
+ pr.run(amount=1.5, fiat_currency="USD", order_id="run-42", memo="agent top-up")
28
+ # {"id": "...", "pay_link": "https://send21.io/p/...", "raw": {...}}
29
+ """
30
+
31
+ def __init__(self, currency: str, network: str, address: str,
32
+ api_key: Secret = Secret.from_env_var("SEND21_API_KEY"),
33
+ mcp_url: str = MCP_URL, expiry_hours: Optional[int] = None,
34
+ client: Any = None) -> None:
35
+ self.currency, self.network, self.address = currency, network, address
36
+ self.api_key, self.mcp_url, self.expiry_hours = api_key, mcp_url, expiry_hours
37
+ self._client = client
38
+
39
+ def _get_client(self):
40
+ if self._client is None:
41
+ self._client = Send21MCPClient(self.mcp_url, self.api_key.resolve_value() or "")
42
+ return self._client
43
+
44
+ @component.output_types(id=str, pay_link=str, raw=dict)
45
+ def run(self, amount: float, fiat_currency: str, order_id: str, memo: str = "",
46
+ idempotency_key: Optional[str] = None) -> dict:
47
+ if Decimal(str(amount)) <= 0:
48
+ raise ValueError("amount must be positive")
49
+ args = build_create_args(amount=Decimal(str(amount)), fiat_currency=fiat_currency,
50
+ currency=self.currency, network=self.network, address=self.address,
51
+ order_id=order_id, memo=memo,
52
+ idempotency_key=idempotency_key or f"{order_id}-{uuid.uuid4()}",
53
+ expiry_hours=self.expiry_hours)
54
+ res = self._get_client().call("create_payment_request", args)
55
+ if "id" not in res or "payPath" not in res:
56
+ raise RuntimeError(f"unexpected send21 response: {res}")
57
+ return {"id": str(res["id"]), "pay_link": pay_link(res["payPath"]), "raw": res}
58
+
59
+ def to_dict(self) -> dict:
60
+ return default_to_dict(self, currency=self.currency, network=self.network, address=self.address,
61
+ api_key=self.api_key.to_dict(), mcp_url=self.mcp_url,
62
+ expiry_hours=self.expiry_hours)
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: dict) -> "Send21PaymentRequest":
66
+ deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
67
+ return default_from_dict(cls, data)
@@ -0,0 +1,26 @@
1
+ """Webhook signature check.
2
+
3
+ Header X-Send21-Signature: sha256=<lowercase hex HMAC-SHA256 of the raw body>, keyed with the
4
+ endpoint signing secret. Check the raw bytes before parsing. X-Send21-Delivery is reused on
5
+ retries, so store it and ignore repeats. No timestamp header is documented.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ from typing import Optional
12
+
13
+ SIGNATURE_HEADER = "X-Send21-Signature"
14
+ DELIVERY_HEADER = "X-Send21-Delivery"
15
+ EVENT_HEADER = "X-Send21-Event"
16
+
17
+
18
+ def sign(raw: bytes, secret: str) -> str:
19
+ return "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
20
+
21
+
22
+ def verify_send21_signature(raw_body: bytes, signature_header: Optional[str], secret: str) -> bool:
23
+ """True only if the header matches the HMAC of the raw body. Constant-time compare."""
24
+ if not secret or not isinstance(signature_header, str) or not isinstance(raw_body, (bytes, bytearray)):
25
+ return False
26
+ return hmac.compare_digest(sign(bytes(raw_body), secret).encode(), signature_header.encode())
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: send21-haystack
3
+ Version: 0.1.0
4
+ Summary: Haystack component that asks send21 to prepare a non-custodial payment request (pay link). Not an official deepset integration.
5
+ Project-URL: Repository, https://github.com/send21io/send21-haystack
6
+ Project-URL: Issues, https://github.com/send21io/send21-haystack/issues
7
+ Author-email: send21 <info@send21.io>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,haystack,mcp,payments,send21
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: haystack-ai>=2.0
13
+ Requires-Dist: mcp>=1.2
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # send21-haystack
19
+
20
+ A Haystack 2.x component that asks send21 to prepare a payment request and returns a pay link, plus a webhook signature helper.
21
+
22
+ This is a community package. It is **not an official deepset integration**.
23
+
24
+ send21 prepares payment instructions. A person opens the pay link and signs in their own wallet; funds go straight from the payer's wallet to the receiver's wallet. send21 never holds keys or funds, never signs and never broadcasts. Use an API key with the `drafts:write` scope only. That key can prepare a payment request but cannot move funds.
25
+
26
+ ## Install
27
+
28
+ Not on PyPI yet. Install from git:
29
+
30
+ ```sh
31
+ pip install git+https://github.com/send21io/send21-haystack
32
+ ```
33
+
34
+ ## Usage in a pipeline
35
+
36
+ ```python
37
+ from haystack import Pipeline
38
+ from send21_haystack import Send21PaymentRequest
39
+
40
+ # reads SEND21_API_KEY from the environment (scope: drafts:write)
41
+ payment = Send21PaymentRequest(currency="USDC", network="base", address="0xYourReceivingAddress")
42
+
43
+ p = Pipeline()
44
+ p.add_component("payment", payment)
45
+ out = p.run({"payment": {"amount": 1.50, "fiat_currency": "USD",
46
+ "order_id": "run-42", "memo": "agent top-up"}})
47
+ print(out["payment"]["pay_link"]) # https://send21.io/p/...
48
+ ```
49
+
50
+ Outputs: `id`, `pay_link`, `raw` (the full tool result).
51
+
52
+ Offline: pass `client=MockSend21Client()` to get deterministic fake ids with no network. See `examples/pipeline.py`.
53
+
54
+ ## What it sends
55
+
56
+ The component calls the send21 MCP server at `https://send21.io/mcp` (Streamable HTTP, `Authorization: Bearer <key>`), tool `create_payment_request`, with these fields only:
57
+
58
+ `fiatCurrency`, `fiatAmount`, `options: [{currency, network, address}]`, `orderId`, `memo`, `expiryHours` (null if unset), `idempotencyKey`.
59
+
60
+ It reads `id` and `payPath` from the result; the pay link is `https://send21.io` + `payPath`. Arguments are checked against the server's live `inputSchema` before the call.
61
+
62
+ ## Webhooks
63
+
64
+ ```python
65
+ from send21_haystack import verify_send21_signature
66
+
67
+ ok = verify_send21_signature(raw_body_bytes, headers.get("X-Send21-Signature"), signing_secret)
68
+ ```
69
+
70
+ `X-Send21-Signature` is `sha256=<lowercase hex HMAC-SHA256 of the raw body>` keyed with the endpoint signing secret. Check the raw bytes before parsing. Store `X-Send21-Delivery` and ignore repeats; deliveries can be retried and arrive out of order. A stdlib receiver is in `examples/webhook_receiver.py`.
71
+
72
+ ## Tests
73
+
74
+ ```sh
75
+ pip install -e ".[dev]" && pytest
76
+ ```
77
+
78
+ Tests run fully offline (the MCP client is mocked).
79
+
80
+ ## Unverified
81
+
82
+ Not confirmed against the live API yet; check https://send21.io/docs and the live MCP `tools/list` first.
83
+
84
+ - The `status` and `expiresAt` values in the `create_payment_request` response, and the exact `payPath` format. Only `id` and `payPath` are relied on.
85
+ - Exact accepted `currency` and `network` names for `options`; the examples use `USDC` / `base`.
86
+ - Whether API keys can target testnet. Assume mainnet; use small amounts.
87
+ - Webhook payload fields beyond `event` and `data.orderId` (for example `paymentRequestId`, `fiatAmount`).
88
+ - Whether a key's scopes can be read through the API. Create the key with `drafts:write` only.
89
+
90
+ ## Related
91
+
92
+ - Agent spend-cap handoff example: https://github.com/send21io/send21-over-budget-handoff
93
+ - More examples: https://github.com/send21io/send21-examples
94
+ - Docs: https://send21.io/docs
95
+
96
+ ## License and contact
97
+
98
+ MIT. Questions: info@send21.io
@@ -0,0 +1,8 @@
1
+ send21_haystack/__init__.py,sha256=4KukQgGASF_fY_mRI-taPkU_rw7kvLNJVqvgX8Dcf04,404
2
+ send21_haystack/client.py,sha256=7TY4vJxw-GMxsfANDvN9k4HWOvvz1gFXHFgWWcNFj2A,5101
3
+ send21_haystack/component.py,sha256=Wq44p1A1cma71wrBzkmdGv5gRH6p8FRsKXfBI9P0qQ4,3134
4
+ send21_haystack/webhook.py,sha256=SaibRu5aXQZHvG5srUXZaqzpaSzuR2MxgZQv9z5ZQo4,1043
5
+ send21_haystack-0.1.0.dist-info/METADATA,sha256=rPHaysQxZZN4tL91LZtad8EplsiqIxujvtvavlYhkQ4,4038
6
+ send21_haystack-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ send21_haystack-0.1.0.dist-info/licenses/LICENSE,sha256=_sIkanGpKqb_AgEqV25-w5X9loAc_g6r7DYjJc4lKWI,1063
8
+ send21_haystack-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 send21
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.