1 What you get
An API key, and one endpoint to post to. Your script generates an encryption key, encrypts the secret on your own machine, and sends us the encrypted blob. We store something we cannot read and give you back an ID. You build the link.
The payload is just text, so what it holds is up to you: a new starter's temporary password, an API key you have just minted for a customer, a rotated client secret going to the app owner, recovery codes, a WiFi passphrase, a certificate password. Anything that has to travel by email and should not still be sitting in that mailbox next year.
There is no endpoint that accepts plaintext. If there were, we would have your secrets and the promise on the front page would be a lie. The encryption is your side of the deal. Read the next section before you email us.
Can you do this?
The work is all on your side. Here is what it comes to.
2 What your side has to do
There is no SDK. The API is one form-encoded POST, so the interesting work all happens before the request:
- Generate 32 random bytes for a key and 12 random bytes for an IV, from a cryptographic random source, not
rand(). - Encrypt with AES-GCM-256 and keep the authentication tag on the end of the ciphertext, which is what most libraries do by default.
- Base64 the ciphertext and the IV.
- URL-encode the base64 key and put it after the
#of the link. - Keep the API key in an environment variable or a secret store, never in the script and never in git.
In Python that is about twenty lines, and the whole thing is below. Node and Go are similar. PowerShell can do it, but AES-GCM lives in .NET classes rather than a cmdlet, so budget an afternoon.
If your language has no AES-GCM in a maintained library, do not improvise one. Use the website instead, or ask us and we will say whether it is worth your time.
3 What we handle
Storage, expiry, deletion, the burn-after-reading interstitial that survives link scanners, and the page the recipient opens. We never see a key, so we cannot help with a lost one. There is no reset and no second copy: if a link is lost before it is read, generate a new one.
The whole thing
Working Python. Needs cryptography and requests.
4 One function, start to finish
import os, base64, urllib.parse, requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
API_KEY = os.environ["TAMINGSHARE_KEY"]
UA = "Mozilla/5.0 (compatible; acme-secret-share/1.0)"
def secure_link(text, expiry="1week", burn=True):
"""Encrypt text locally, store the ciphertext, return a shareable link."""
key, iv = os.urandom(32), os.urandom(12)
ciphertext = AESGCM(key).encrypt(iv, text.encode(), None)
r = requests.post(
"https://tamingshare.com/create.php",
headers={"Authorization": f"Bearer {API_KEY}", "User-Agent": UA},
data={
"ciphertext": base64.b64encode(ciphertext),
"iv": base64.b64encode(iv),
"expiry_period": expiry,
"burn_after_reading": "1" if burn else "0",
},
timeout=15,
)
r.raise_for_status()
data = r.json()
fragment = urllib.parse.quote(base64.b64encode(key).decode(), safe="")
return f"https://tamingshare.com/view/id={data['paste_id']}#{fragment}"
# A new starter, one link each, gone the moment they read it.
for name, username, password in new_starters:
print(name, secure_link(f"Username: {username}\nTemporary password: {password}"))
# An API key minted for a customer. Short window, single read.
secure_link(customer_key, expiry="24hours")
# A rotated client secret for the app owner, who will need it twice.
secure_link(f"{app_name} secret, expires {expiry_date}\n{secret}",
expiry="1week", burn=False)
The function does not care what the text is. What changes per use is the expiry and whether it burns: a password the person types once should burn on first read, while a secret someone has to paste into a config file twice is better left readable until it expires.
Send the link and the thing it belongs to in different channels. The link in email and the username over Teams means one intercepted message is not enough.
Line by line
What each part is doing, and where people get it wrong.
5 The key and the IV
os.urandom(32) is the encryption key and it exists only inside your process and inside the link you print. Do not derive it from a password, a username or a counter, and do not reuse one across pastes.
The IV is 12 bytes, fresh for every single paste. Both are generated inside the function, per call, which is the only reason the loop at the bottom is safe. Reusing an IV under the same key breaks AES-GCM outright, not slightly.
6 The encryption
AESGCM(key).encrypt(iv, plaintext, None) returns the ciphertext with the 16-byte authentication tag appended, which is the layout the browser expects when it decrypts. The third argument is additional authenticated data and we do not use any.
If your library returns the tag separately, concatenate it onto the end yourself. We reject anything under 16 bytes, but we cannot tell a wrongly ordered tag from a correct one, so that mistake shows up later as a paste that will not decrypt.
7 The request
Form-encoded POST to https://tamingshare.com/create.php, with two headers that matter. Authorization: Bearer ts_live_... identifies your key. The User-Agent has to be something recognisable, because our host blocks several HTTP libraries' defaults, Python's included, and answers with an HTML challenge page and a 403 instead of JSON.
Everything is validated before anything is stored, and a refused request still counts against your rate limit. Check your own inputs rather than using us as the validator.
8 The response
{
"success": true,
"paste_id": "9f3c1e...",
"receipt_token": "4a7b02...",
"base_url": "https://tamingshare.com/",
"expiry_time": 1757000000,
"burn_after": true,
"time_remaining": "1 week"
}
paste_id is public and goes in the link. receipt_token is yours alone and must never appear in the link you send out: keep it if you want to check later whether the person opened it. expiry_time is the Unix timestamp of deletion.
9 Building the link
https://tamingshare.com/view/id=<paste_id>#<url-encoded base64 key>
URL-encode the base64 key before it goes after the #. Base64 contains + and /, which a raw fragment will mangle. In Python that is urllib.parse.quote(..., safe=""); skipping the safe="" leaves the slashes alone and produces links that fail for some secrets and work for others, which is the worst kind of bug to chase.
Browsers never send the part after # to a server. The ID travels to us. The key travels only to the person you sent the link to.
Reference
Fields, limits and every error we return.
10 Request fields
| Field | Required | Notes |
|---|---|---|
ciphertext | yes | Base64 of the AES-GCM-256 output, tag included. Up to 5 MiB decoded on a key, 1 MiB from the website form. |
iv | yes | Base64 of exactly 12 random bytes, fresh per paste. |
expiry_period | no | 5mins, 30mins, 1hour, 24hours, 1week, 2weeks, 1month, 2months, 3months, 6months, 1year. Defaults to 1hour, and your key carries its own ceiling. |
burn_after_reading | no | 1 deletes the paste the first time it is opened. Defaults to 0. |
11 Limits
| Limit | Default |
|---|---|
| Requests | 30 per hour, 200 per day |
| Live pastes at once | 500 |
| Longest expiry | 1 year |
| Size | 5 MiB of ciphertext |
That covers an onboarding intake or a round of secret rotation, and deliberately falls short of migrating a password database. Every ceiling is set per key, so if normal use is hitting one, email us and we will raise it rather than have you write a retry loop.
Over the limit you get a 429 with a Retry-After header in seconds. The live-paste cap counts pastes that have not yet expired or been read, so it falls on its own as links get used.
12 Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | missing_fields | No ciphertext or no iv. |
| 400 | invalid_base64 | One of them is not clean base64. |
| 400 | invalid_iv_length | The IV is not 12 bytes. |
| 400 | invalid_ciphertext_length | Under 16 bytes, or over the ceiling, which the response names in bytes. |
| 400 | invalid_expiry | expiry_period is not one of the listed values. |
| 400 | expiry_too_long | Past your key's ceiling, which the response names in seconds. |
| 401 | invalid_key | Key is wrong, revoked, or the header is malformed. |
| 405 | method_not_allowed | Anything other than POST. |
| 429 | rate_limited | Over the hourly or daily count. |
| 429 | live_paste_cap | You are holding the maximum number of unexpired pastes. |
| 503 | rate_limiter_unavailable | Our limiter could not be read, so the request was refused. Retry. |
| 500 | server_error | Ours. Retry once, then tell us. |
Two things beyond a password
Checking whether it was opened, and sending a rendered report.
13 Did they open it?
https://tamingshare.com/receipt/id=<receipt_token>
This tells you whether the link is still sitting there unread, without opening it yourself. On a burn-after-reading paste, checking by clicking the real link would destroy it before the recipient ever saw it.
14 Sending a rendered HTML report
Add .h to the end of the fragment and the link renders its plaintext as a page rather than showing it as text. The flag rides on the fragment, so we never learn which pastes are reports, and nothing stored says so either.
https://tamingshare.com/view/id=<paste_id>#<url-encoded key>.h
Rendering is a per-key permission and it is off by default, so unless we have switched it on for your key the paste stays text whatever you append. Pastes made through the website form never render as a page. That keeps the feature from turning into a page host, and it means a stolen key produces an inert page at worst.
Write the HTML to survive a hostile frame. It runs with sandbox="", so there are no scripts, no navigation and no external requests: inline your CSS, embed images as data: URIs, draw charts as SVG, and print URLs as text because links will not go anywhere. Stay under 5 MiB after encryption, which is the HTML itself plus 16 bytes. Embedded PNGs are what usually blow it.
A report expires on the same ladder as any other paste, up to a year on a standard key, so give one somebody has to refer back to a month rather than an hour. Leave burn_after_reading off for those: a report that deletes itself the first time it is opened cannot be reread, and the reader has the Download button and the Burn button either way.
The reader gets the report filling the window, plus a floating control showing how long the link has left, a Download button for the exact HTML file, and a two-step Burn button that deletes it for everyone immediately. The report shows "From" and your key's label, so give the key a name your recipients will recognise.
Gotchas
15 Before you debug anything
An HTML body means your User-Agent. A 403 with a page instead of JSON is our host blocking your library's default header. Set anything recognisable and it goes away.
A fresh IV every time. Generate the key and the IV inside the loop, per paste, as the example does.
Burn-after-reading survives link scanners. Microsoft and Google preview links in email, and a preview would otherwise burn the paste before the person saw it. Opening the link shows a confirmation page first, and only a real click destroys the content.
Nothing is recoverable. We hold no key and no second copy. Lose a link and you make a new one.
16 Getting a key
Email [email protected] and say roughly what you are building and how many links a week you expect. We create keys by hand, and we send yours back on a TamingShare link.
Keys are issued per company rather than per person, and yours will look like ts_live_ followed by 32 characters. If one leaks, email us and we will kill it. Revocation is instant and does not touch links you have already created.