developer documentation
build on skunk.to
everything you need to integrate file uploads, pastes, and "login with skunk.to" into your own apps and scripts.
getting started
skunk.to gives you two ways to integrate with the platform:
- oauth 2.0 — add "login with skunk.to" to your site so users can authenticate with their skunk.to account.
- api keys — hit the api directly to upload files and create pastes from scripts and servers.
to get going, head to the developer dashboard and create an oauth application or an api key.
two paths, one api
oauth is for acting on behalf of users; api keys are for acting as yourself. both reach the same upload & paste endpoints.
oauth 2.0
skunk.to implements the oauth 2.0 authorization code flow with support for pkce (proof key for code exchange), so users can authorize your app without ever sharing their password.
standards compliant
our implementation follows rfc 6749 (oauth 2.0) and rfc 7636 (pkce), so it works with any standard oauth 2.0 client library.
set up your application
- open the developer dashboard
- click create application
- fill in your application details:
- name — what users see on the consent screen
- redirect uris — where users land after authorizing
- application type — confidential (server-side, can store a secret) or public (spa / mobile, uses pkce)
- save your client id and client secret
store your secret now
the client secret is shown only once. if you lose it, you'll have to regenerate it.
authorization flow
1redirect to authorization
send users to the authorization endpoint:
GET https://skunk.to/oauth/authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid profile:read
&state=RANDOM_STATE_STRING| parameter | description |
|---|---|
| response_typerequired | must be code |
| client_idrequired | your application's client id |
| redirect_urirequired | must match one of your registered redirect uris |
| scopeoptional | space-separated list of scopes. defaults to openid |
| staterecommended | random string to prevent csrf. returned unchanged. |
| code_challengepkce | sha-256 hash of code_verifier (public clients) |
| code_challenge_methodpkce | must be S256 |
2user authorizes
the user sees a consent screen with your app name and requested permissions. after approval they're redirected to your redirect_uri with an authorization code:
https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_STRING3exchange code for tokens
https://skunk.to/oauth/tokenPOST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRETresponse
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"scope": "openid profile:read"
}4use the access token
GET https://skunk.to/oauth/userinfo
Authorization: Bearer ACCESS_TOKEN5refresh tokens
when the access token expires, exchange the refresh token for a new one:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=YOUR_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRETpkce for public clients
for spas, mobile apps, and other public clients that can't safely store a secret, use pkce.
generate code verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// store codeVerifier (e.g. sessionStorage), send codeChallenge in the auth requestadd to the authorization request:
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256add to the token exchange:
&code_verifier=CODE_VERIFIERavailable scopes
| scope | description |
|---|---|
openid | verify user identity (required for login) |
profile:read | read basic profile info (username, id) |
profile:write | update profile information |
uploads:read | view the user's uploaded files |
uploads:write | upload files on behalf of the user |
pastes:read | view the user's pastes |
pastes:write | create pastes on behalf of the user |
oauth endpoints
| endpoint | url |
|---|---|
| authorization | https://skunk.to/oauth/authorize |
| token | https://skunk.to/oauth/token |
| user info | https://skunk.to/oauth/userinfo |
| token revocation | https://skunk.to/oauth/revoke |
login button integration
drop a "login with skunk.to" button onto your site:
<a href="https://skunk.to/oauth/authorize?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid profile:read&state=RANDOM_STATE"
class="skunk-login-btn">
login with skunk.to
</a>api keys
api keys give you direct api access without a user authorization step. they're ideal for:
- automated scripts and tools
- server-side integrations
- sharex configuration
- ci/cd pipelines
using api keys
include your api key in the Authorization header. this is the only supported method — there is no query-parameter fallback.
Authorization: Bearer sk_live_xxxx_xxxxxxxxxxxxxxxxone key, three resources
the same api key authenticates /api/upload, /api/paste, and /api/shorten — it acts as your account. uploads and pastes made with a key skip the anonymous "must be encrypted" requirement; short links additionally require your account to have short-link permission.
api key scopes
when creating an api key, you can limit its permissions to specific scopes. this follows the principle of least privilege.
| scope | allows |
|---|---|
uploads:read | list and view your uploads |
uploads:write | upload new files, delete uploads |
pastes:read | list and view your pastes |
pastes:write | create new pastes, delete pastes |
profile:read | view account information |
api reference
user info
/oauth/userinforeturns information about the authenticated user. required scope: openid or profile:read.
{
"sub": "user_abc123",
"name": "johndoe",
"preferred_username": "johndoe",
"picture": null,
"updated_at": 1704067200
}file uploads
/api/uploadupload a file. the request body is multipart/form-data. required scope: uploads:write.
$ curl -X POST "https://skunk.to/api/upload" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@/path/to/file.png" \
-F "expires_in=24"form fields
| field | description |
|---|---|
| filerequired | the file to upload. |
| expires_inoptional | hours until the file is auto-deleted. capped by your tier's retention limit. |
| passwordoptional | encrypt the file at rest with this password. requires ?server_encrypt=true (see below). |
| client_encryptedoptional | true when the bytes are already aes-encrypted in the browser. must be paired with encryption_salt. |
| encryption_saltoptional | base64 salt that accompanies client-encrypted data. |
| query parameter | description |
|---|---|
| ?server_encrypt=trueoptional | opt in to server-side encryption: lets the server derive a key from password and encrypt the file before writing it to disk. off by default — client-side encryption is the intended path. |
anonymous uploads must be encrypted
without an api key, an upload must either be client-encrypted (client_encrypted=true + encryption_salt) or supply a password together with ?server_encrypt=true. authenticated (api-key) uploads have no such requirement.
response
{
"success": true,
"id": "abc123",
"url": "https://skunk.to/abc123",
"raw_url": "https://skunk.to/abc123/raw",
"deletion_key": "del_xyz789",
"deletion_url": "https://skunk.to/abc123/delete?key=del_xyz789"
}pastes
/api/pastecreate a new paste. the request body is json (Content-Type: application/json). required scope: pastes:write.
$ curl -X POST "https://skunk.to/api/paste" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "console.log(\"Hello World\");",
"syntax": "javascript",
"title": "My Code Snippet",
"expires_in": 24
}'json fields
| field | description |
|---|---|
| contentrequired | the paste body. |
| titleoptional | display title. defaults to "untitled". |
| syntaxoptional | language for syntax highlighting, e.g. javascript, plaintext. |
| is_privateoptional | true to keep the paste out of public listings. |
| expires_inoptional | hours until the paste is auto-deleted. capped by your tier's retention limit. |
| passwordoptional | encrypt the paste at rest with this password. requires ?server_encrypt=true. |
| client_encryptedoptional | true when content is already aes-encrypted in the browser. must be paired with encryption_salt. |
| encryption_saltoptional | base64 salt that accompanies client-encrypted content. |
anonymous pastes must be encrypted
without an api key, a paste must either be client-encrypted or supply a password with ?server_encrypt=true. authenticated (api-key) pastes have no such requirement.
response
{
"success": true,
"id": "paste123",
"url": "https://skunk.to/p/paste123",
"raw_url": "https://skunk.to/p/paste123/raw",
"deletion_key": "del_abc456",
"deletion_url": "https://skunk.to/p/paste123/delete?key=del_abc456"
}short links
/api/shortencreate a short link. the request body is json. authentication is required (session or api key) — short links cannot be created anonymously — and your account must have short-link permission, which an administrator grants.
$ curl -X POST "https://skunk.to/api/shorten" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/some/very/long/link",
"slug": "my-link",
"expires_in_days": 30
}'json fields
| field | description |
|---|---|
| urlrequired | destination url. must start with http:// or https:// (max 8192 chars). |
| slugoptional | custom slug, 3–32 chars of letters, numbers, hyphens, and underscores. must be unique. a random slug is generated if omitted. |
| expires_in_daysoptional | days until the link expires. never expires if omitted. |
| warning_reasonoptional | show an interstitial warning before redirecting. one of phishing, malware, scam, nsfw, other. |
response
{
"success": true,
"short_url": "https://skunk.to/s/my-link",
"slug": "my-link",
"warning_reason": null
}deleting content
delete an upload or paste with its deletion_key (returned when you created it). no account or api key is needed — possession of the key authorizes deletion.
browser link
the deletion_url in the create response (e.g. https://skunk.to/abc123/delete?key=…) opens a confirmation page in a browser — visit it and click delete to remove the content. this is the link to store in tools like sharex. for programmatic deletion, issue an http DELETE to the endpoints below.
/api/{id}?key=DELETION_KEY/api/p/{id}?key=DELETION_KEY# delete an upload
$ curl -X DELETE "https://skunk.to/api/abc123?key=del_xyz789"
# delete a paste
$ curl -X DELETE "https://skunk.to/api/p/paste123?key=del_abc456"authenticated users can also delete their own content by id without the deletion key via DELETE /api/my/uploads/{id} and DELETE /api/my/pastes/{id}.
code examples
python — oauth client
import requests
from urllib.parse import urlencode
CLIENT_ID = "sk_your_client_id"
CLIENT_SECRET = "sks_your_client_secret"
REDIRECT_URI = "https://yourapp.com/callback"
# step 1: build the authorization url
auth_params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile:read",
"state": "random_state_string"
}
auth_url = f"https://skunk.to/oauth/authorize?{urlencode(auth_params)}"
# step 2: exchange the code (in your callback handler)
def exchange_code(code):
return requests.post("https://skunk.to/oauth/token", data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
}).json()
# step 3: get user info
def get_user_info(access_token):
return requests.get("https://skunk.to/oauth/userinfo", headers={
"Authorization": f"Bearer {access_token}"
}).json()javascript — file upload with api key
const API_KEY = 'sk_live_xxxx_xxxxxxxxxxxxxxxx';
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('https://skunk.to/api/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: formData
});
return res.json();
}
// usage
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', async (e) => {
const result = await uploadFile(e.target.files[0]);
console.log('Uploaded:', result.url);
});curl — create a paste
$ curl -X POST "https://skunk.to/api/paste" \
-H "Authorization: Bearer sk_live_xxxx_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"content": "Hello, World!",
"syntax": "plaintext",
"title": "My First Paste"
}'error handling
oauth errors
oauth errors follow the rfc 6749 format:
{
"error": "invalid_grant",
"error_description": "Authorization code has expired"
}| error code | description |
|---|---|
invalid_request | missing or invalid parameters |
invalid_client | unknown client or invalid credentials |
invalid_grant | invalid, expired, or revoked authorization code |
unauthorized_client | client not authorized for this grant type |
unsupported_grant_type | grant type not supported |
invalid_scope | requested scope is invalid or unknown |
access_denied | user denied authorization |
api errors
{
"success": false,
"error": "Invalid API key"
}http status codes
| status | meaning |
|---|---|
| 200 | success |
| 400 | bad request — check parameters |
| 401 | unauthorized — invalid or expired token |
| 403 | forbidden — insufficient permissions |
| 404 | resource not found |
| 429 | rate limited — slow down |
| 500 | server error |
need help?
questions or stuck on something? open the developer dashboard or reach out to support.