├── .gitignore ├── client_example └── javascript │ ├── .babelrc │ ├── .eslintrc │ ├── README.md │ ├── config.js │ ├── package.json │ ├── index.js │ ├── gql.js │ ├── request.js │ └── package-lock.json ├── LICENSE ├── README.md ├── FAQ.md └── gqlschema └── public_story_api.graphql /.gitignore: -------------------------------------------------------------------------------- 1 | # Javascript 2 | 3 | node_modules 4 | 5 | # IntelliJ 6 | 7 | .idea 8 | -------------------------------------------------------------------------------- /client_example/javascript/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-2" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /client_example/javascript/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "node": true, 6 | "es6": true, 7 | "browser": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client_example/javascript/README.md: -------------------------------------------------------------------------------- 1 | # Public Story API JavaScript Client Example 2 | 3 | ### Quick Run 4 | - Add your private key, `private_key.pem`, in this folder 5 | - Run the example 6 | ```bash 7 | # Install dependencies 8 | npm install 9 | # Run Example 10 | # Update your iss and kid in the config.js file 11 | npm run example 12 | ``` 13 | -------------------------------------------------------------------------------- /client_example/javascript/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | Endpoint: 'https://api.snapkit.com/v1/stories/public', 3 | PrivateKeyFile: 'private_key.pem', // the path to your private key file 4 | CryptoAlgorithm: 'ES256', 5 | Issuer: 'dcb57664-94ba-469e-ab4c-e2468ad218b9', // 6 | KID: 'dcb57664-94ba-469e-ab4c-e2468ad218b9-v1', // -v> // is 1, 2, etc., to support key rotation 7 | }; 8 | -------------------------------------------------------------------------------- /client_example/javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storykit", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "example": "babel-node index.js" 8 | }, 9 | "dependencies": { 10 | "axios": "^0.18.0", 11 | "babel-preset-es2015": "^6.24.1", 12 | "babel-preset-stage-2": "^6.24.1", 13 | "graphql": "^0.13.2", 14 | "graphql-tag": "^2.9.2", 15 | "jsonwebtoken": "^8.3.0" 16 | }, 17 | "devDependencies": { 18 | "babel-cli": "^6.26.0", 19 | "babel-eslint": "^8.2.3", 20 | "eslint": "^4.19.1", 21 | "eslint-config-airbnb": "^16.1.0", 22 | "eslint-plugin-import": "^2.12.0", 23 | "eslint-plugin-jsx-a11y": "^6.0.3", 24 | "eslint-plugin-react": "^7.9.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client_example/javascript/index.js: -------------------------------------------------------------------------------- 1 | import GQL from './gql'; 2 | import Request from './request'; 3 | 4 | const SearchSnapsWithGeoFilter = () => ( 5 | Request.run( 6 | GQL.SearchSnapsWithGeoFilter.query, 7 | GQL.SearchSnapsWithGeoFilter.variables, 8 | ) 9 | ); 10 | 11 | const SearchSnapsWithOffset = async () => { 12 | /* eslint-disable no-await-in-loop */ 13 | const maxLoopCount = 3; 14 | let offset = 0; 15 | for (let i = 0; i < maxLoopCount; i += 1) { 16 | // A nextSnapOffset of value -1 indicates there is no more 17 | // record after the last Snap returned in this call 18 | if (offset === -1) { 19 | break; 20 | } 21 | const response = await Request.run( 22 | GQL.SearchSnapsWithOffset.query, 23 | GQL.SearchSnapsWithOffset.variables(offset), 24 | ); 25 | offset = response.data.SearchSnaps.nextSnapOffset; 26 | } 27 | }; 28 | 29 | const run = async () => { 30 | await SearchSnapsWithGeoFilter(); 31 | await SearchSnapsWithOffset(); 32 | }; 33 | run(); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Snapchat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client_example/javascript/gql.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import { print } from 'graphql/language/printer'; 3 | 4 | const geoFilter = { 5 | geotype: 'CIRCLE', 6 | circle: { 7 | center: { 8 | latitude: 37.7975529, 9 | longitude: -122.4034846, 10 | }, 11 | radius_in_meters: 10000, 12 | }, 13 | }; 14 | 15 | // Example query for SearchSnaps with geo_filter 16 | const SearchSnapsWithGeoFilter = { 17 | query: print(gql` 18 | query ($geo_filter: GeoFilterInput, $media_format_filter: MediaFormatFilterInput, $order: SearchResultsOrderType!, $paging: PagingInput!) { 19 | SearchSnaps(geo_filter: $geo_filter, media_format_filter: $media_format_filter, order: $order, paging: $paging) { 20 | snaps { 21 | title 22 | embed_url 23 | media { 24 | media_type 25 | orientation_type 26 | camera_position 27 | } 28 | } 29 | } 30 | } 31 | `), 32 | variables: { 33 | geo_filter: geoFilter, 34 | media_format_filter: { 35 | media_type: 'VIDEO', 36 | orientation_type: 'PORTRAIT', 37 | facing_type: 'FRONT_FACING', 38 | }, 39 | order: 'REVERSE_CHRONOLOGICAL', 40 | paging: { 41 | offset: 0, 42 | count: 5, 43 | }, 44 | }, 45 | }; 46 | 47 | // Example query for SearchSnaps with offset 48 | const SearchSnapsWithOffset = { 49 | query: print(gql` 50 | query ($geo_filter: GeoFilterInput, $order: SearchResultsOrderType!, $paging: PagingInput!) { 51 | SearchSnaps(geo_filter: $geo_filter, order: $order, paging: $paging) { 52 | snaps { 53 | title 54 | embed_url 55 | } 56 | nextSnapOffset 57 | } 58 | } 59 | `), 60 | variables: offset => ({ 61 | geo_filter: geoFilter, 62 | order: 'REVERSE_CHRONOLOGICAL', 63 | paging: { 64 | offset, 65 | count: 5, 66 | }, 67 | }), 68 | }; 69 | 70 | export default { 71 | SearchSnapsWithGeoFilter, 72 | SearchSnapsWithOffset, 73 | }; 74 | -------------------------------------------------------------------------------- /client_example/javascript/request.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import fs from 'fs'; 3 | import axios from 'axios'; 4 | import crypto from 'crypto'; 5 | import Config from './config'; 6 | 7 | /** 8 | * @param query for "query" field in the post body 9 | * @param variables for "variables" field in the post body 10 | * @returns {Promise>} GraphQL response body 11 | */ 12 | const run = (query, variables) => { 13 | console.log('------------------------------------------------------'); 14 | 15 | // Assemble the GraphQL POST body 16 | const postBody = { 17 | query, 18 | variables, 19 | }; 20 | const postBodyStr = JSON.stringify(postBody); 21 | 22 | // Generate JWT Token 23 | const headers = { 24 | header: { 25 | typ: 'JWT', // This is not required. jsonwebtoken will auto add type: 'JWT' if payload is an object 26 | alg: Config.CryptoAlgorithm, 27 | kid: Config.KID, 28 | }, 29 | }; 30 | const privateKey = fs.readFileSync(Config.PrivateKeyFile); 31 | const payload = { 32 | iss: Config.Issuer, 33 | aud: 'PublicStoryKitAPI', 34 | exp: 1844975504, 35 | hash: crypto.createHash('sha256').update(postBodyStr).digest('hex'), 36 | }; 37 | 38 | /* 39 | jwt.sign will convert the above content to the following JSON format: 40 | 41 | header = { 42 | alg: 'algorithm', 43 | typ: 'JWT', 44 | kid: 'xxx', 45 | } 46 | 47 | payload = { 48 | iss: 'xxx', 49 | aud: 'xxx', 50 | exp: 123, 51 | iat: 123, 52 | hash: 'xxx', 53 | } 54 | */ 55 | const token = jwt.sign( 56 | payload, 57 | privateKey, 58 | headers, 59 | ); 60 | 61 | console.log(`Generated JWT token: \n${token}`); 62 | console.log(`GraphQL query: \n${JSON.stringify(postBody, null, 2)}`); 63 | 64 | // Send a POST request to the SnapKit GraphQL endpoint 65 | return axios({ 66 | method: 'POST', 67 | url: Config.Endpoint, 68 | headers: { 69 | 'X-Snap-Kit-S2S-Auth': `Bearer ${token}`, 70 | }, 71 | data: postBodyStr, 72 | }) 73 | .then((response) => { 74 | console.log(JSON.stringify(response.data, null, 2)); 75 | console.log(`return snaps #${response.data.data.SearchSnaps.snaps.length}`); 76 | return response.data; 77 | }) 78 | .catch((error) => { 79 | console.log(error); 80 | return error; 81 | }); 82 | }; 83 | 84 | export default { 85 | run, 86 | }; 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Story Kit API 2 | 3 | # Story Kit API Documentation 4 | 5 | Story Kit includes a [GraphQL](https://graphql.org/ "GraphQL") API service that lets partners search Snapchat Stories by location, time, caption, media type, official username, and more. 6 | 7 | Browse this repo for guidance on using the Story Kit API. 8 | 9 | ## Getting started 10 | 11 | ### Create an ESDSA key pair 12 | 1. Create the private key: 13 | `$ openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem` 14 | 2. From your private key, create the public key: 15 | `$ openssl ec -in private-key.pem -pubout -out public-key.pem` 16 | 17 | ### Send the public key to your Snap advocate 18 | Your advocate at Snap will assign an `iss` and a`kid` to you: 19 | 20 | - `iss` 21 | - A UUID string created to uniquely identify the partner's organization 22 | - *Example: `dcb57664-94ba-469e-ab4c-e2468ad218b9` is the `iss` we created for `SnapAPITest`* 23 | - `kid` 24 | - The `iss-version number` 25 | - `version number` is to support key rotation in the future 26 | - *Example: `dcb57664-94ba-469e-ab4c-e2468ad218b9-v1` is the `kid` for `SnapAPITest`* 27 | 28 | ### Send a GraphQL request to Story Kit API endpoint 29 | The Story Kit API only accepts HTTP POST requests that carry the query in the request body and the partner-signed [JWT token](https://jwt.io/introduction/ "JWT token") in the `X-Snap-Kit-S2S-Auth` HTTP header. Learn how to successfully send an HTTP POST request in the steps below. 30 | 31 | #### 1. Construct a [GraphQL](https://graphql.org/ "GraphQL") query. 32 | 33 | The Story Kit API is [GraphQL](https://graphql.org/ "GraphQL") based. For details, see the GraphQL 34 | 35 | defined in [public_story_api.graphql](gqlschema/public_story_api.graphql). 36 | 37 | To construct the query body, use whichever [GraphQL client libraries](https://graphql.org/code/#graphql-clients "GraphQL client libraries") you prefer. 38 | 39 | #### 2. Construct the `X-Snap-Kit-S2S-Auth` header. 40 | 41 | The header is of the format: 42 | `Bearer .` 43 | 44 | The token is a [JWT token](https://jwt.io/introduction/ "JWT token"). Below, see the field descriptions and learn how to generate the signature. 45 | 46 | ``` 47 | header = '{ 48 | "alg":"ES256", 49 | "typ":"JWT", 50 | "kid": -v> // is 1, 2, etc., to support key rotation 51 | }' 52 | 53 | payload = '{ 54 | "iss": , 55 | "aud": "PublicStoryKitAPI", 56 | "exp": , 57 | "iat": , 58 | "hash": 59 | }' 60 | ``` 61 | 62 | Here's how to sign the token: 63 | 64 | ``` 65 | privateKey = 66 | unsignedToken = base64.rawurlencode(header) + '.' + base64.rawurlencode(payload) 67 | signature = base64.rawurlencode(ecdsaSigner.sign(privateKey, unsignedToken)) 68 | authorizationHeader = 'Bearer ' + unsignedToken + '.' + signature 69 | request.header(‘X-Snap-Kit-S2S-Auth’, authorizationHeader) 70 | ``` 71 | 72 | And here is an example of the `X-Snap-Kit-S2S-Auth` header: 73 | 74 | #### Header 75 | ``` 76 | { 77 | "alg": "ES256", 78 | "kid": "dcb57664-94ba-469e-ab4c-e2468ad218b9-v1", 79 | "typ": "JWT" 80 | } 81 | ``` 82 | #### Payload 83 | ``` 84 | { 85 | "aud": "PublicStoryKitAPI", 86 | "exp": 1529640526, 87 | "hash": "85f07ad28767eaab637a5f78ed3ebc23f58595d08db14df7d8f2df312106cab9", 88 | "iat": 1527630526, 89 | "iss": "dcb57664-94ba-469e-ab4c-e2468ad218b9" 90 | } 91 | ``` 92 | 93 | #### Generated token 94 | `Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6ImRjYjU3NjY0LTk0YmEtNDY5ZS1hYjRjLWUyNDY4YWQyMThiOS12MSIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJQdWJsaWNTdG9yeUtpdEFQSSIsImV4cCI6MTUyOTY0MDUyNiwiaGFzaCI6Ijg1ZjA3YWQyODc2N2VhYWI2MzdhNWY3OGVkM2ViYzIzZjU4NTk1ZDA4ZGIxNGRmN2Q4ZjJkZjMxMjEwNmNhYjkiLCJpYXQiOjE1Mjc2MzA1MjYsImlzcyI6ImRjYjU3NjY0LTk0YmEtNDY5ZS1hYjRjLWUyNDY4YWQyMThiOSJ9.OUKz6vPYH6VQVk0KK30qOaUWhvc50WH1HAa-VoXxHnkuG5JmZRFPizbDdEOjK8qSDNGPKuo_X--4MM7faBgLGw` 95 | 96 | JWT offers many [token signing libraries](https://jwt.io/) in various languages. To simplify token signing, choose one that fits easily with your tech stack. 97 | 98 | #### 3. Send an HTTP POST request to the API endpoint 99 | 100 | The final step is sending your HTTP POST request. Make sure it follows these conventions: 101 | - Carries the query in the request body 102 | - Includes the partner signed [JWT token](https://jwt.io/introduction/ "JWT token") in the `X-Snap-Kit-S2S-Auth` HTTP header 103 | 104 | Try it out with the beta test endpoint: `https://kit.snap-dev.net/v1/stories/public` 105 | 106 | #### Example Code 107 | 108 | Need an example? Check out the [Node.js example](client_example/javascript) in client_example folder 109 | 110 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # Story Kit API FAQ 2 | 3 | The Story Kit API is defined by our [GraphQL schema](https://github.com/Snapchat/storykit/blob/master/gqlschema/public_story_api.graphql). 4 | 5 | ## What kind of Snaps does the Story Kit API serve? 6 | 7 | Regular Snaps are private, but a lot of Snaps are for everyone to see. The Story Kit API serves only these *non-private* Snaps from Snapchatters. There are two types of non-private Snaps — we call them *Stories*: 8 | 9 | #### [Our Story](https://support.snapchat.com/en-US/a/our-story) 10 | When a Snapchatter posts to Our Story, the Snap lives publicly for 90 days. Snapchat attaches location information (lat/lng, city, or country) to Our Stories — but the Snaps are not attributed. 11 | 12 | #### [My Story](https://support.snapchat.com/en-US/article/my-story) 13 | The Story Kit API currently supports *Official Stories*, which are My Stories posted by a verified celebrity. When a Snapchatter posts a Story and sets their sharing setting to *Everyone*, its lifespan is 24 hours. In this case, the username and display name are attached, but location information is hidden. We plan to expand this to our full My Story corpus, which includes *Popular* and *Public User* Stories. 14 | 15 | The Story Kit API serves all **Our Stories** and **Official Stories**. In the API response, we do not explicitly include which type of Story each Snap is, but you can differentiate by checking the `expiration_time_secs` field, which indicates when the Snap’s lifespan ends. 16 | 17 | ## Why are some fields in the response empty? 18 | 19 | We hide some of the fields to protect user privacy. For example, we hide `display_name` from Our Story results. In My Story results, city and state information is hidden from the `title` field. 20 | 21 | ## Can we get a collection of related Stories based on any criteria? 22 | From the Snaps posted to Our Story and My Story, we build two kinds of collection of Stories: 23 | 24 | **Per-user**: A collection of Stories a particular user posts to My Story. These Stories live for 24 hours, with location information hidden. 25 | 26 | **Per-feature**: A collection of Stories posted to Our Story that share a particular feature, like topic or location. The Stories live for 90 days, and personally identifiable information is hidden. 27 | 28 | Note that the above collection of Stories does not represent the entire pool of Snaps available via the Snap Kit API. These collected Stories are limited subsets of all the Snaps. To surface all the available Snaps in the Story Kit API, please use the `SearchSnaps` query. 29 | 30 | ## What kinds of content do API queries surface? 31 | You can query for different kinds of content: 32 | 33 | `SearchSnaps` searches for matching Stories posted to Our Story or My Story. 34 | 35 | `SearchStories` searches for matching Stories in Snapchat's curated Story collections. The only difference here is that we've done some of the categorizing for you already and hidden certain PII. 36 | 37 | `SearchUserStories` searches for matching Stories in Snapchat's per-user collection only. 38 | 39 | You can see the up-to-date list of possible queries in [public_story_api.graphql](https://github.com/Snapchat/storykit/blob/master/gqlschema/public_story_api.graphql) at any time. 40 | 41 | ## map.snapchat.com shows location-based Snaps. Can we get similar Stories based on the geo location? 42 | 43 | Story Kit API doesn’t provide exactly the same contents as map.snapchat.com. However, the `SearchSnaps` query with `GeoCircleInput` in `GeoFilterInput` provides similar results. This query has fewer ranking features applied, so it yields more results, with less sophistication. 44 | 45 | ## Can we get the list of popular users, so that we can easily search by user using `ContentFilterInput`. 46 | 47 | Currently, we only provide Official Stories, or Snaps posted by anyone on our list of a few thousand verified celebrity Snapchatters. We do not provide a list of popular users. 48 | 49 | ## Can we get a list of popular locations so that we can easily search by location using `GeoFilterInput`? 50 | 51 | Currently, we do not provide the list of popular locations. 52 | 53 | ## Can we get a list of currently trending locations so that we can easily search by location using `GeoFilterInput`? 54 | 55 | Currently, we do not provide the list of locations trending now. 56 | 57 | ## Can we get media download link? 58 | 59 | Instead of a media download link, Story Kit API provides an embed link (please adjust ```a_url_suffix``` to what is in the API response): 60 | 61 | ```