SignNow电子签

SignNow电子签api使用

Posted by catmai on February 23, 2024

SignNow是国外的电子签平台,中文的博客非常少,在此记录一下。

官方API文档传送门:

https://docs.signnow.com/docs/signnow/get-started

ps全是生肉

1.创建账号、创建application

直接去平台创建账号、创建一个application。可以现在沙箱环境创建,因为正式环境签合同是收费的,包含了真实身份验证等,沙箱环境可以随意测试。

可以看API文档进行创建,上面写得很详细。

2.如何使用

总体分为以下几个步骤:

1.上传合同模板(Template),设置签名角色

2.应用程序获取模板,通过模板创建文档(Document),回填合同模板内需要的内容(可选项)

3.应用程序发送签约邀请邮件

4.应用程序创建签约完成事件(Event)

5.事件发起回调

6.应用程序标记业务签约完成,删除事件

上传合同模板

这一步可以在平台内完成,上传template(请注意不是document)

并按需设置template的签约角色(甲方、乙方)、签约位置和填充的字段。这个步骤在平台内很明显,可以自行摸索。

photo1

应用程序获取模板

应用程序端需要在项目启动前初始化signNow的各项内容,创建signNow客户端。

SNClientBuilder:

public class SNClientBuilder {

    private String apiUrl;
    private String clientId;
    private String clientSecret;
    private String basicAuthHeader;
    private Client basicClient;
    private WebTarget snApiUrl;
    private static volatile SNClientBuilder instance;
    protected final static Variant defaultVariant = new Variant(
            MediaType.APPLICATION_JSON_TYPE,
            Locale.getDefault(),
            StandardCharsets.UTF_8.name()
    );

    public static SNClientBuilder get() throws SNException {
        SNClientBuilder localInstance = instance;
        if (localInstance == null) {
            throw new SNException("SNClientBuilder must be initialized with API connection prerequisites") {};
        }
        return localInstance;
    }

    /**
     * SNClientBuilder singleton initializer
     *
     * @param apiUrl
     * @param clientId
     * @param clientSecret
     * @return
     */
    public static SNClientBuilder get(String apiUrl, String clientId, String clientSecret) {
        SNClientBuilder localInstance = instance;
        if (localInstance == null) {
            synchronized (SNClientBuilder.class) {
                localInstance = instance;
                if (localInstance == null) {
                    instance = localInstance = new SNClientBuilder(apiUrl, clientId, clientSecret);
                }
            }
        }
        return localInstance;
    }

    private SNClientBuilder(String apiUrl, String clientId, String clientSecret) {
        this.apiUrl = apiUrl;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.basicAuthHeader = "Basic "+encodeClientCredentials(clientId, clientSecret);
        basicClient = ClientBuilder.newClient().register(MultiPartFeature.class);
        snApiUrl = basicClient.target(apiUrl);
    }

    private String encodeClientCredentials(String client, String secret){
        return Base64.getEncoder().encodeToString((client + ":" + secret).getBytes(StandardCharsets.UTF_8));
    }

    /**
     * Produces new API client instance for SignNow user
     * @param email
     * @param password
     * @return
     * @throws SNAuthException
     */
    public SNClient getClientForExistingUser(String email, String password) throws SNAuthException {
        User user = null;
        try {
            Form authForm = new Form();
            authForm.param("grant_type", "password")
                    .param("username", email)
                    .param("password", password)
                    .param("scope", "*");
            Response response = getOAuthRequest(authForm);
//            User.UserAuthResponce authData = response.readEntity(User.UserAuthResponce.class);
            Map<String,String> loginData = response.readEntity(Map.class);
            user = new User(email, loginData.get("access_token"), loginData.get("refresh_token"));
        } catch (Exception e) {
            e.printStackTrace();
            throw new SNAuthException(e.getMessage(), e);
        }
        return new SNClient(
                snApiUrl,
                user
        );
    }

    protected void refreshToken(User user) throws SNException {
        try {
            Form authForm = new Form();
            authForm.param("grant_type", "refresh_token")
                    .param("refresh_token", user.getRefreshToken())
                    .param("scope", "*");
            Response response = getOAuthRequest(authForm);
            User.UserAuthResponce auth = response.readEntity(User.UserAuthResponce.class);
            user.setToken(auth.token);
            user.setRefreshToken(auth.refreshToken);
        } catch (Exception e) {
            throw new SNException(e.getMessage(), e) {};
        }
    }

    private Response getOAuthRequest(Form authForm) throws SNAuthException {
        Response response = snApiUrl.path("/oauth2/token")
                .request(MediaType.APPLICATION_JSON_TYPE)
                .header(Constants.AUTHORIZATION, basicAuthHeader)
                .post(Entity.entity(authForm, MediaType.APPLICATION_FORM_URLENCODED_TYPE));
        if (response.getStatus() >= 500) {
            throw new SNAuthException(response.readEntity(Errors.class).errors.stream()
                    .map(err -> err.code + ": " + err.message)
                    .collect(Collectors.joining("\n")));
        } else if (response.getStatus() >= 400) {
            throw new SNAuthException(response.getStatus() + ": " + response.readEntity(AuthError.class).error);
        }
        return response;
    }

    /**
     * Get new API client instance for already authenticated user. Argument object can be obtained from {@link SNClient#getUser()}
     *
     * @param user
     * @return
     * @throws SNAuthException
     */
    public SNClient getClientForAuthenticatedUser(User user) throws SNAuthException {
        SNClient cli = new SNClient(
                snApiUrl,
                user
        );
        try {
            cli.checkAuth();
        } catch (SNAuthException e) {
            System.out.println("AUTH: " + e.getAuthError() + ", " + e.getMessage());
            if (e.getAuthError() == AuthError.Type.INVALID_TOKEN) {
                try {
                    refreshToken(user);
                } catch (SNException refreshEx) {
                    throw new SNAuthException(refreshEx.getMessage(), refreshEx) {};
                }
            }
        } catch (SNException e) {
            throw new SNAuthException(e.getMessage(), e);
        }
        return cli;
    }

    /**
     * Create new user in SignNow account
     * @param email
     * @param password
     * @return user ID
     * @throws SNAuthException
     */
    public String createUser(String email, String password) throws SNAuthException {
        try {
            Response response = snApiUrl.path("/user")
                    .request(MediaType.APPLICATION_JSON)
                    .header(Constants.AUTHORIZATION, basicAuthHeader)
                    .post(Entity.entity(new User.UserCreateRequest(email, password), defaultVariant));
            if (response.getStatus() >= 400) {
                throw new SNAuthException(response.readEntity(Errors.class).errors.get(0).message);
            } else {
                return response.readEntity(User.UserCreateResponce.class).id;
            }
        } catch (SNAuthException e) {
            throw e;
        } catch (Exception e) {
            e.printStackTrace();
            throw new SNAuthException(e.getMessage(), e);
        }
    }

}

SignNowService:

@Component
public class SNApiService {

    private static final Logger logger = LoggerFactory.getLogger(SNApiService.class);

    @Autowired
    private SignNowConfig signNowConfig;

    @PostConstruct
    private void initClientBuilder() {
        logger.info("初始化电子签.....");
        SNClientBuilder.get(signNowConfig.getApiUrl(), signNowConfig.getClientId(), signNowConfig.getClientSecret());
    }

    public SNClient getSNClient(String email, String password) {
        try {
            return SNClientBuilder.get().getClientForExistingUser(email, password);
        } catch (SNException e) {
            logger.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    public SNClient getSNClient(User snUser) {
        try {
            return SNClientBuilder.get().getClientForAuthenticatedUser(snUser);
        } catch (SNException e) {
            logger.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }


    public void createNewSNUser(String email, String password) {
        try {
            SNClientBuilder.get().createUser(email, password);
        } catch (SNException e) {
            logger.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }
}

UserData:

@Component
public class UserData {
    private SNClient snClient;
    private Set<DocumentInfo> userDocuments = new HashSet<>();

    public SNClient getSnClient() {
        return snClient;
    }

    public void setSnClient(SNClient snClient) {
        this.snClient = snClient;
    }

    public Set<DocumentInfo> getUserDocuments() {
        return Collections.unmodifiableSet(userDocuments);
    }

    public void setUserDocuments(Set<DocumentInfo> documents) {
        userDocuments.addAll(documents);
    }

    public void addDocument(DocumentInfo document) {
        this.userDocuments.add(document);
    }
}

ok,回到业务本身,当需要触发电子签的时候,首先从template中创建文档。

public class SignNowServiceImpl implements SignNowService {

    @Autowired
    private SNApiService snApiService;

    @Autowired
    private Provider<UserData> provider;

    @Autowired
    private SignNowConfig signNowConfig;
    
	//signNow开发者账号
    private static final String LOGIN_EMAIL = "[email protected]";
    //signNow开发者账号的密码
    private static final String LOGIN_PWD = "XXXXXX";

    private static final Logger logger = LoggerFactory.getLogger(SignNowServiceImpl.class);

    @Override
    public void login() {
        logger.info("登录signNow.....");
        final UserData userData = provider.get();
        userData.setSnClient(snApiService.getSNClient(LOGIN_EMAIL,LOGIN_PWD));
        userData.setUserDocuments(getDocumentList());
    }
    
    /**
    * 从模板中创建文档,这边的templateId可以从各种方式获得。根据业务判断。
    * @return 返回的是创建后的文档id
    */
    @Override
    public String createDocumentFromTemplate(String templateId,String documentName) throws SNException {
        if (isUserNotAuthorized()){
            login();
        }
        logger.info("====从模板创建文档====");
        return provider.get().getSnClient().templatesService().createDocumentFromTemplate(templateId,documentName);
    }
    
    /**
     * 登录态是否有效
     * @return
     */
    private boolean isUserNotAuthorized() {
        if (provider.get() == null) {
            return true;
        }
        return provider.get().getSnClient() == null;
    }
    
    /**
     * 获得用户文档列表
     * @return
     * @throws SNException
     */
    private Set<DocumentInfo> getDocumentList() {
        try {
            return provider.get()
                    .getSnClient()
                    .documentsService()
                    .getDocuments()
                    .stream()
                    .map(doc -> new DocumentInfo(doc.id, doc.document_name))
                    .collect(Collectors.toSet());
        } catch (SNException e) {
            e.printStackTrace();
        }
        return new HashSet<>();
    }
    
    //略..
    
}

补充用到的类:

ApiSerivce:

public abstract class ApiService {
    protected SNClient client;

    protected ApiService(SNClient client) {
        this.client = client;
    }
}

Template服务:

public interface Templates {
    String createTemplate(String sourceDocumentId, String templateName) throws SNException;

    String createDocumentFromTemplate(String sourceTemplateId, String newDocumentName) throws SNException;
}

Template服务实现:

public class TemplatesService extends ApiService implements Templates {
    public TemplatesService(SNClient client) {
        super(client);
    }

    public String createTemplate(String sourceDocumentId, String templateName) throws SNException {
        return client.post(
                "/template",
                null,
                new Template.CreateRequest(templateName, sourceDocumentId),
                GenericId.class
        ).id;
    }

    public String createDocumentFromTemplate(String sourceTemplateId, String newDocumentName) throws SNException {
        return client.post(
                "/template/{sourceTemplateId}/copy",
                Collections.singletonMap("sourceTemplateId", sourceTemplateId),
                new Template.CopyRequest(newDocumentName),
                GenericId.class
        ).id;
    }
}

通过上面的代码可以通过template创建出文档,接下来是可选项,回填文档的预填字段。

回填文档内容

一种是普通field,一种是smartFields。区别在于,smartFields可以有一些选项框可以设置,传入true即为选中。但是效果不明显,并且字段key不能重复。读者可以自己做两个demo尝试一下效果。

@Override
public void preFillFieldsOfDocument(String documentId, List<Field> fieldList) throws SNException{
    if (StringUtils.isEmpty(fieldList)){
        return;
    }
    if (isUserNotAuthorized()){
        login();
    }
    Map<String,List<Field>> params = new HashMap<>();
    params.put("fields",fieldList);
    provider.get().getSnClient().put("/v2/documents/{document_id}/prefill-texts",Collections.singletonMap("document_id", documentId),params,String.class);
}

@Override
public void preFillSmartFieldsOfDocument(String documentId, List<Map<String,String>> data) throws SNException{
    if (StringUtils.isEmpty(data)){
        return;
    }
    if (isUserNotAuthorized()){
        login();
    }
    Map<String,Object> params = new HashMap<>();
    params.put("data",data);
    params.put("client_timestamp", DateUtils.getNowDate().getTime());
    provider.get().getSnClient().post("/document/{document_id}/integration/object/smartfields",Collections.singletonMap("document_id", documentId),params,String.class);
}

Field对象:

public class Field {

    private String field_name;

    private String prefilled_text;

    public Field(String field_name, Object prefilled_text) {
        if (null != prefilled_text){
            this.prefilled_text = String.valueOf(prefilled_text);
        }else {
            this.prefilled_text = "";
        }
        this.field_name = field_name;
    }
}

邀请用户签约

这里入参的角色需要和平台内文档设置的角色对应,role内包含了签约人的邮箱,这一步将会有signNow发送给对方一封签约邮件。

/**
 * 
 * @param documentId 文档ID
 * @param inviteRoles 邀请的角色
 * @param subject 文档标题、邮件标题
 * @throws SNException
*/
@Override
@Async
public void inviteToSignDocument(String documentId, List<Document.InviteRole> inviteRoles,String subject) throws SNException {
    if (isUserNotAuthorized()){
        login();
    }
    Document.SigningInviteWithRolesRequest rolesRequest = new Document.SigningInviteWithRolesRequest(LOGIN_EMAIL,inviteRoles);
    rolesRequest.message = "xxx";
    rolesRequest.subject = subject;
    provider.get().getSnClient().documentsService().sendDocumentSignInvite(documentId,rolesRequest);
}

Doucment对象:(btw 如果文中有漏这些对象,可以从官方的github demo中找到。)

@JsonIgnoreProperties(ignoreUnknown = true)
public class Document extends GenericId {
    @JsonProperty("user_id")
    public String user_id;
    @JsonProperty("document_name")
    public String document_name;
    @JsonProperty("page_count")
    public String pageCount;
    public String created;
    public String updated;
    @JsonProperty("original_filename")
    public String originalFilename;
    @JsonProperty("version_time")
    public String versionTime;
    /**
     * This one stands for some internal data info, does not correspond to document sign ID (free form invite) or group invite ID
     */
    @JsonProperty("field_invites")
    public List<FieldInvite> fieldInvites;
    /**
     * Free form invites info
     */
    public List<DocumentSignRequestInfo> requests;

    public static class SigningLinkRequest {
        @JsonProperty("document_id")
        public String documentId;

        public SigningLinkRequest(String documentId) {
            this.documentId = documentId;
        }
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class SigningLinkResponce {
        public String url;
        @JsonProperty("url_no_signup")
        public String urlNoSignup;
    }

    public static class SigningInviteRequest {
        public final String from;
        public final String to;
        public List<String> cc = new ArrayList<>();
        public String subject;
        public String message;

        public SigningInviteRequest(String from, String to) {
            this.from = from;
            this.to = to;
        }
    }

    public static class SigningInviteWithRolesRequest {
        public final String from;
        public final List<InviteRole> to;
        public List<String> cc = new ArrayList<>();
        public String subject;
        public String message;

        public SigningInviteWithRolesRequest(String from, List<InviteRole> to) {
            this.from = from;
            this.to = to;
        }
    }

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public static class InviteRole {
        public final String email;
        public final String role_id;
        public final String role;
        public Integer order = 1;
        public String password;
        @JsonProperty("expiration_days")
        public Integer expireAfterDays = null;
        @JsonProperty("reminder")
        public Integer remindAfterDays = null;
        @JsonProperty("redirect_uri")
        public String redirectUri = "";

        public InviteRole(String email, String role,String roleId) {
            this.email = email;
            this.role = role;
            this.role_id = roleId;
        }

        @JsonGetter("authentication_type")
        public String getAuthType() {
            if (password != null) {
                return "password";
            }
            return null;
        }
    }

    public enum FieldType {
        SIGNATURE("signature"),
        TEXT("text"),
        INITIALS("initials"),
        CHECKBOX("checkbox"),
        ENUMERATION("enumeration");

        private final String name;

        FieldType(String name) {
            this.name = name;
        }

        @JsonSetter
        public static FieldType typeOf(String name) {
            for (FieldType type : values()) {
                if (type.name.equalsIgnoreCase(name)) {
                    return type;
                }
            }
            throw new IllegalArgumentException(name + " field not supported.");
        }

        @JsonCreator
        @Override
        public String toString() {
            return name;
        }
    }

    public static class FieldsUpdateRequest {
        public final List<Field> fields;

        public FieldsUpdateRequest(List<Field> fields) {
            this.fields = fields;
        }
    }

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public static class Field {
        public int x;
        public int y;
        public int width;
        public int height;
        @JsonProperty("page_number")
        public int pageNumber;
        public String role;
        public boolean required;
        public FieldType type;
        public String label;
        @JsonProperty("prefilled_text")
        public String prefilledText;

        public String getType() {
            return type.toString();
        }
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class FieldInvite extends GenericId {
        public String status;
        public String email;
        public String role;
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class DocumentSignRequestInfo extends GenericId {
        @JsonProperty("signer_email")
        public String signerEmail;
        @JsonProperty("originator_email")
        public String originatorEmail;
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class DocumentDownloadLink {
        public String link;
    }

}
@JsonIgnoreProperties(ignoreUnknown = true)
public class GenericId {
    public String id;

    @JsonIgnore
    private Map<String, String> additionalMembers = new HashMap<>();

    @JsonAnySetter
    public void ignored(String name, Object value) {
        if (value != null) {
            additionalMembers.put(name, value.toString());
        } else {
            additionalMembers.put(name, null);
        }
    }

    /**
     * For debug purposes, contains API response object additional information that is not supported via DTO.
     *
     * @return
     */
    public Map<String, String> getAdditionalMembers() {
        return Collections.unmodifiableMap(additionalMembers);
    }
}

创建事件、删除事件

/**
 * 为文档创建一个完成签约的事件
 */
@Override
public void createEvent(String documentId) throws SNException{
    if (isUserNotAuthorized()){
        login();
    }
    Event event = new Event("document.complete");
    event.setEntity_id(documentId);
    //这里设置回调地址
    event.setAttributes(new EventAttributes(signNowConfig.getCallBackUrl()));
    provider.get().getSnClient().post("/api/v2/events",null,event,String.class);
}

//获取现在已经设置了的事件
@Override
public List<SignEventVo> getEventList() throws SNException, IOException {
    if (isUserNotAuthorized()){
        login();
    }
    OkHttpClient client = new OkHttpClient();
    Request request = new Request.Builder()
        .url(signNowConfig.getApiUrl() + "/api/v2/events")
        .get()
        .addHeader("Content-Type", "application/json")
        .addHeader("Authorization", "Basic " + signNowConfig.getBasicToken())
        .build();
    Response response = client.newCall(request).execute();
    if (response.isSuccessful()) {
        if (StringUtils.isNotNull(response.body())) {
            String result =  response.body().source().readUtf8();
            response.close();
            List<SignEventVo> data = JSONArray.parseArray(JSONObject.parseObject(result).get("data").toString(),SignEventVo.class);
            return data;
        }
    }
    return null;
}

//删除事件
@Override
public String deleteEvent(String documentId) throws SNException, IOException {
    if (isUserNotAuthorized()){
        login();
    }
    List<SignEventVo> signEventVos = getEventList();
    if (StringUtils.isNotEmpty(signEventVos)){
        //如果不为空,遍历获得ID
        String deleteId = StringUtils.EMPTY;
        for (SignEventVo signEventVo : signEventVos) {
            if (signEventVo.getEntity_unique_id().equals(documentId)){
                deleteId = signEventVo.getId();
                break;
            }
        }
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
            .url(signNowConfig.getApiUrl() + "/api/v2/events/" + deleteId)
            .delete(null)
            .addHeader("Content-Type", "application/json")
            .addHeader("Authorization", "Basic " + signNowConfig.getBasicToken())
            .build();
        Response response = client.newCall(request).execute();
        if (response.isSuccessful()) {
            if (StringUtils.isNotNull(response.body())) {
                String result =  response.body().source().readUtf8();
                response.close();
                return result;
            }
        }
        return StringUtils.EMPTY;
    }else {
        return StringUtils.EMPTY;
    }
}

当这个文档双方都签约完成了,会发送回调。回调内包含了documentId,根据documentId标记业务完成。

3.总结

整体来说signNow的流程就是这般,可以按需使用。基本包含了创建文档、发送邀请链接、获取事件回调的流程,能够满足基本业务使用。