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:

  1. oauth 2.0 — add "login with skunk.to" to your site so users can authenticate with their skunk.to account.
  2. 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

  1. open the developer dashboard
  2. click create application
  3. fill in your application details:
    • name — what users see on the consent screen
    • redirect uris — where users land after authorizing
    • application typeconfidential (server-side, can store a secret) or public (spa / mobile, uses pkce)
  4. 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:

http
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
parameterdescription
response_typerequiredmust be code
client_idrequiredyour application's client id
redirect_urirequiredmust match one of your registered redirect uris
scopeoptionalspace-separated list of scopes. defaults to openid
staterecommendedrandom string to prevent csrf. returned unchanged.
code_challengepkcesha-256 hash of code_verifier (public clients)
code_challenge_methodpkcemust 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:

callback
https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_STRING

3exchange code for tokens

POSThttps://skunk.to/oauth/token
http
POST /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_SECRET

response

json
{
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
    "scope": "openid profile:read"
}

4use the access token

http
GET https://skunk.to/oauth/userinfo
Authorization: Bearer ACCESS_TOKEN

5refresh tokens

when the access token expires, exchange the refresh token for a new one:

http
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_SECRET

pkce 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

javascript
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 request

add to the authorization request:

http
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256

add to the token exchange:

http
&code_verifier=CODE_VERIFIER

available scopes

scopedescription
openidverify user identity (required for login)
profile:readread basic profile info (username, id)
profile:writeupdate profile information
uploads:readview the user's uploaded files
uploads:writeupload files on behalf of the user
pastes:readview the user's pastes
pastes:writecreate pastes on behalf of the user

oauth endpoints

endpointurl
authorizationhttps://skunk.to/oauth/authorize
tokenhttps://skunk.to/oauth/token
user infohttps://skunk.to/oauth/userinfo
token revocationhttps://skunk.to/oauth/revoke

login button integration

drop a "login with skunk.to" button onto your site:

login with skunk.to
html
<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.

http
Authorization: Bearer sk_live_xxxx_xxxxxxxxxxxxxxxx

one 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.

scopeallows
uploads:readlist and view your uploads
uploads:writeupload new files, delete uploads
pastes:readlist and view your pastes
pastes:writecreate new pastes, delete pastes
profile:readview account information

api reference

user info

GET/oauth/userinfo

returns information about the authenticated user. required scope: openid or profile:read.

json
{
    "sub": "user_abc123",
    "name": "johndoe",
    "preferred_username": "johndoe",
    "picture": null,
    "updated_at": 1704067200
}

file uploads

POST/api/upload

upload a file. the request body is multipart/form-data. required scope: uploads:write.

curl
$ 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

fielddescription
filerequiredthe file to upload.
expires_inoptionalhours until the file is auto-deleted. capped by your tier's retention limit.
passwordoptionalencrypt the file at rest with this password. requires ?server_encrypt=true (see below).
client_encryptedoptionaltrue when the bytes are already aes-encrypted in the browser. must be paired with encryption_salt.
encryption_saltoptionalbase64 salt that accompanies client-encrypted data.
query parameterdescription
?server_encrypt=trueoptionalopt 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

json
{
    "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

POST/api/paste

create a new paste. the request body is json (Content-Type: application/json). required scope: pastes:write.

curl
$ 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

fielddescription
contentrequiredthe paste body.
titleoptionaldisplay title. defaults to "untitled".
syntaxoptionallanguage for syntax highlighting, e.g. javascript, plaintext.
is_privateoptionaltrue to keep the paste out of public listings.
expires_inoptionalhours until the paste is auto-deleted. capped by your tier's retention limit.
passwordoptionalencrypt the paste at rest with this password. requires ?server_encrypt=true.
client_encryptedoptionaltrue when content is already aes-encrypted in the browser. must be paired with encryption_salt.
encryption_saltoptionalbase64 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

json
{
    "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"
}

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.

DELETE/api/{id}?key=DELETION_KEY
DELETE/api/p/{id}?key=DELETION_KEY
curl
# 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

python
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

javascript
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
$ 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:

json
{
    "error": "invalid_grant",
    "error_description": "Authorization code has expired"
}
error codedescription
invalid_requestmissing or invalid parameters
invalid_clientunknown client or invalid credentials
invalid_grantinvalid, expired, or revoked authorization code
unauthorized_clientclient not authorized for this grant type
unsupported_grant_typegrant type not supported
invalid_scoperequested scope is invalid or unknown
access_denieduser denied authorization

api errors

json
{
    "success": false,
    "error": "Invalid API key"
}

http status codes

statusmeaning
200success
400bad request — check parameters
401unauthorized — invalid or expired token
403forbidden — insufficient permissions
404resource not found
429rate limited — slow down
500server error

need help?

questions or stuck on something? open the developer dashboard or reach out to support.