DEV Community

Cover image for How to get a webhook when a TON address changes
apiTON
apiTON

Posted on Originally published at apiton.org

How to get a webhook when a TON address changes

Most code that follows something on TON ends up as a loop: read the address, compare it with what
you saw last time, sleep, repeat. v2.Watch in the apiton API does that
loop for you. You name an address, and apiton stores an event for every change and pushes it to
your server, or keeps it for you to collect.

This is the walkthrough: a key, a watch, a handler that checks the signature, and what to do if you
have no server at all.

What you can watch

A watch is one address, and the server works out what kind it is:

  • An NFT item (a Telegram username, a +888 number, a .ton domain, a gift or any other NFT) is watched for its sale and auction.
  • Any other address is watched for its transactions, above an amount you choose.
Event Means
listed put on sale
price the sale price changed
delisted the sale or auction ended with the same owner
sold the sale or auction ended with a new owner
auction_start an auction began
bid a higher bid
auction_ending under 2 hours left
tx a wallet's transaction

Step 1: get a key

Send /get_api to @apitonBot. The key looks like 123456789:aB3dE… and
goes in a header on every call. v2.Watch needs one: a watch has to belong to somebody.

Step 2: create a watch

curl -X POST https://app.apiton.org/v2.Watch/Create \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer 123456789:aB3dE…' \
  -d '{"address":"UQCdqXGvONLwOr3zCNX5FjapflorB6ZsOdcdfLrjsDLt3AF4",
       "webhook_url":"https://example.com/apiton","min_amount":1}'
Enter fullscreen mode Exit fullscreen mode

The answer has the watch and a webhook_secret starting with whsec_. It is shown once, so
store it now. It signs your deliveries, and it is not your API key.

For a wallet, min_amount is the smallest amount that raises an event, in GRAM, 1 by default. 0
means every transaction. The webhook_url must be HTTPS on port 443 or 8443 on a public host, and
redirects are not followed.

The watch starts from the state it finds: what happened before Create raises nothing.

Step 3: receive the webhook

Each event arrives as a POST of JSON with three headers: X-Apiton-Signature,
X-Apiton-Timestamp and X-Apiton-Event-Id. The signature is sha256= plus the hex HMAC-SHA256
of <timestamp>.<body>, keyed with your secret.

Check it over the raw body, as received. Parsing the JSON and serialising it again gives
different bytes.

import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["APITON_WEBHOOK_SECRET"]
seen = set()  # use your database


def verify(headers, body: bytes) -> bool:
    ts = headers.get("X-Apiton-Timestamp", "0")
    if abs(time.time() - int(ts)) > 300:
        return False  # a replayed request
    want = hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + want, headers.get("X-Apiton-Signature", ""))


@app.post("/apiton")
def apiton():
    body = request.get_data()
    if not verify(request.headers, body):
        abort(401)
    event = request.get_json()
    if event["id"] not in seen:
        seen.add(event["id"])
        queue_for_later(event)  # your slow work goes here, not in this request
    return "", 204
Enter fullscreen mode Exit fullscreen mode

Two things in that handler are deliberate.

Delivery is at least once. If your 2xx gets lost, the same event comes again with the same id.
So remember the ids you have processed.

Answer first, work later. Only a 2xx within 5 seconds counts as delivered. A slow handler counts
as a failure even if its work finished.

To try it, call Test. It sends a test event right away and tells you what your endpoint answered:

curl -X POST https://app.apiton.org/v2.Watch/Test \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer 123456789:aB3dE…' -d '{"id":"41"}'
Enter fullscreen mode Exit fullscreen mode

When your endpoint is down

A failed delivery is retried after 1, 5 and 30 minutes, then every 30 minutes. After about 24 hours
of failures the webhook is paused and nothing is pushed until a Test gets through. Nothing is lost
in the meantime: events stay readable for 7 days, as the next section shows. List shows each
webhook's state and last error.

No server? Use a cursor

Leave out webhook_url. Every event is stored anyway, so you can collect them from a script or a
cron job:

curl -X POST https://app.apiton.org/v2.Watch/Poll \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer 123456789:aB3dE…' \
  -d '{"after_id":"0","limit":50}'
Enter fullscreen mode Exit fullscreen mode

Send next_after_id as the next after_id. Ids only grow, so a stored cursor never skips an event.
Keep an eye on the cost: each Poll is one request of your plan, while webhook deliveries cost none.
A free key's 10,000 requests a month is one Poll every 4½ minutes and nothing else.

Limits

Plan Watches Each address read every Webhook pushes per hour Price for 30 days
free 3 3 minutes 10 0
builder 30 30 seconds 100 5 GRAM or 1,000 Stars
pro 30 30 seconds 100 30 GRAM or 6,000 Stars

Events above the hourly cap are not lost: they stay in Poll. Builder and pro also raise the
general API limits; /upgrade in @apitonBot shows them.

Worth knowing

  • A status change arrives one read later. listed, sold and the like are reported when two reads in a row agree, so one bad read cannot fire delisted and listed. Prices and bids need only one.
  • A busy wallet. One read returns up to 16 transactions. A wallet with more between two reads gets the newest 16 and truncated: true; read the rest with Address/Transactions.
  • Amounts are in GRAM, the coin of TON, formerly Toncoin. The API's symbol for it stays TON.

If you only want the alert in Telegram and no code, send the address to
@apitonBot and tap Watch. The full reference is at
docs.apiton.org.

Top comments (0)