pushary
Human-in-the-loop decisions for AI products, in Python. Create a decision, ask a specific end-user to approve it, and resume on their answer via webhook or poll.
This is the Python counterpart of the @pushary/server SDK. It is zero
dependency (Python standard library only) and targets Python 3.9 and newer.
Installation
pip install pushary
API key
The SDK needs your full API key (pk_xxx.sk_xxx), which includes the secret
half. Never expose it in client-side code. Get your key from your
Pushary dashboard.
import os
from pushary import PusharyServer
pushary = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
Two calls to add human-in-the-loop
Connect an end-user's phone once, then ask them whenever your agent needs a human. Requires the Partner plan.
# 1. Connect an end-user's phone (keyless, no account for them). Show the link.
enrolled = pushary.enroll("user_123")
# Render enrolled["universalLink"] as a button or QR. One tap turns on approvals.
# 2. Ask that person and block until they answer. Fail-closed approved flag.
decision = pushary.decisions.ask(
question="Issue a $50 refund?",
external_id="user_123",
type="confirm", # confirm | select | input
)
if decision["approved"]:
issue_refund()
ask creates the decision, derives a collision-safe idempotency key, and polls
durably until the human answers or the deadline passes (default 55 seconds,
serverless-safe). approved is true only when the person actually said yes, so a
declined, expired, or unanswered decision safely blocks the action. For longer
waits or your own resume logic, use create plus a webhook or get below.
Human-in-the-loop decisions
A decision asks one of your end-users to approve something and then lets your product resume once they answer. Use it for the moments where a human should be in the loop: releasing funds, sending an outbound message, running a destructive action, or confirming an AI-proposed change.
The flow has three parts:
- Create a decision and notify the end-user.
- Resume when they answer, either by handling the webhook or by polling.
- Optionally answer or cancel on their behalf from your own surface.
Create a decision
By default create is async: it returns right away with a decisionId and a
pollUrl, which suits serverless functions that cannot hold a request open
while a human decides. Pass wait=True to block for up to about 55 seconds in
case the human answers quickly.
Always pass idempotency_key so a retried call does not ask the same human
twice.
decision = pushary.decisions.create(
"Approve the $4,200 payout to Acme Corp?",
type="confirm",
external_id="user_123",
agent_name="Billing Agent",
context="Invoice INV-8842, net-30, first payout to this vendor.",
callback_url="https://your-app.com/webhooks/pushary",
expires_in_seconds=3600,
wait=True,
timeout_seconds=50,
idempotency_key="payout-INV-8842",
)
if decision["answered"]:
print("Resolved fast:", decision["value"])
else:
print("Still pending, poll:", decision["pollUrl"])
For a multiple choice decision, pass type="select" with at least two
options. For free text, pass type="input" and an optional placeholder.
decision = pushary.decisions.create(
"Which shipping speed should we book?",
type="select",
options=["Standard", "Express", "Overnight"],
external_id="user_123",
)
Poll for the answer
If you did not wait, or the wait window closed before the human answered, poll
for the outcome. Pass wait=N to long-poll for up to N seconds so the call
returns as soon as they answer rather than on your next loop.
result = pushary.decisions.get(decision["decisionId"], wait=50)
if result["status"] == "answered":
print("Answer:", result["value"])
elif result["status"] == "expired":
print("The decision expired before anyone answered.")
Answer or cancel on their behalf
If your own interface collected the answer, record it so any waiting call resolves. Cancel a decision to close it when it is no longer needed.
pushary.decisions.answer(decision["decisionId"], "yes")
pushary.decisions.cancel(decision["decisionId"])
Webhooks
When the end-user answers, Pushary POSTs the result to your callback_url. The
request carries an X-Pushary-Signature header, an HMAC-SHA256 hex digest of
the raw request body signed with your webhook secret. Verify it against the raw
bytes you received, before parsing the JSON, so a change to spacing or key order
cannot slip past the check.
Fetch or rotate the secret from the SDK:
secret = pushary.decisions.get_webhook_secret()
rotated = pushary.decisions.rotate_webhook_secret()
A Flask handler that verifies the signature and resumes your work:
import os
from flask import Flask, request, abort
from pushary import verify_webhook_signature
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["PUSHARY_WEBHOOK_SECRET"]
@app.post("/webhooks/pushary")
def pushary_webhook():
signature = request.headers.get("X-Pushary-Signature")
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
abort(401)
payload = request.get_json()
decision_id = payload["decisionId"]
answer = payload.get("value")
# Resume your work now that the human has answered.
return "", 204
request.data is the raw request body Flask captured, which is exactly what the
signature was computed over. Do not re-serialize the parsed JSON before
verifying.
Errors
Every method returns the parsed JSON body as a dict. A non-2xx response raises
PusharyError, which carries the HTTP status and the reason the server
reported.
from pushary import PusharyError
try:
pushary.decisions.get("does-not-exist")
except PusharyError as error:
print(error.status, error.message)
Security
- Keep your API key and webhook secret in environment variables.
- Rotate the webhook secret from the dashboard or via
rotate_webhook_secret()if it is ever exposed. - API keys are site-scoped, so a key can only reach its own decisions.
License
MIT. See LICENSE.
Release files for pushary 1.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pushary-1.4.0.tar.gz | 22.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pushary-1.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 42.1 kB
Release files / pushary-1.4.0.tar.gz
| Download URL | pushary-1.4.0.tar.gz |
|---|---|
| Size | 22.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
99e7a475f355e5026f066505499ac93b34baab7b8d8d3dd10a8df53ff8556014
|
|
BLAKE2b-256 checksum How to use checksums |
d286000c5eb1805ff1391fa7d154d29814bc4926bd782d2bb68ebb5cdfec419d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logRelease files / pushary-1.4.0-py3-none-any.whl
| Download URL | pushary-1.4.0-py3-none-any.whl |
|---|---|
| Size | 19.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
48ebd3d129b2f0e86153dab54cc1b4fe0acb943dc120a3bbd079638196d1d923
|
|
BLAKE2b-256 checksum How to use checksums |
78e8a4ca5a5fd870786f5b680c0a67496c280370351b5a663317c64b8b540d40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency log